7 Commits
Author SHA1 Message Date
shmyga b459eef34b feat(preset): add gitea preset
test / test (push) Successful in 2m20s
2026-10-03 16:34:32 +03:00
shmyga 244f995490 ci(action): add gitea actions
test / test (push) Successful in 2m18s
2026-09-30 15:03:56 +03:00
shmyga 75adb282e7 ci(version): 0.3.1 2026-09-29 19:24:02 +03:00
shmyga eacfa17e40 feat(preset): add '--all-extras' flag to poetry install command 2026-09-29 19:23:41 +03:00
shmyga f4448835ab docs: update README 2026-09-15 19:35:00 +03:00
shmyga 732cec8771 ci(version): 0.3.0 2026-09-15 17:21:45 +03:00
shmyga 5cf71fcae1 feat: update default presets 2026-09-15 17:21:23 +03:00
21 changed files with 252 additions and 54 deletions
+20
View File
@@ -0,0 +1,20 @@
name: release
on:
push:
tags: ["*"]
env:
POETRY_HTTP_BASIC_InfernalGames_USERNAME: ${{ vars.PUBLISH_USERNAME }}
POETRY_HTTP_BASIC_InfernalGames_PASSWORD: ${{ secrets.PUBLISH_TOKEN }}
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: https://git.shmyga.ru/InfernalGames/project-manager@master
with:
setup-python: true
- run: pm build -vv
- run: pm publish -vv
+14
View File
@@ -0,0 +1,14 @@
name: test
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: https://git.shmyga.ru/InfernalGames/project-manager@master
with:
setup-python: true
- run: pm lint -vv
- run: pm test -vv
+20 -7
View File
@@ -1,9 +1,17 @@
# Project Manager
Automatically detects modules and executes commands
Supported types:
- python (poetry)
- nodejs (npm)
- docker
## Install
```bash
pipx install project-manager -i https://git.shmyga.ru/InfernalGames/project-manager/packages
pipx install project-manager -i https://git.shmyga.ru/api/packages/InfernalGames/pypi/simple/ --pip-args="--extra-index-url https://pypi.org/simple"
```
## Usage
@@ -14,17 +22,16 @@ pipx install project-manager -i https://git.shmyga.ru/InfernalGames/project-mana
pm [-v] [-vv]
```
![pm show](docs/Screenshot_0.png "pm show")
### Run task
```bash
pm <task>[:<tag>] [-v] [-vv]
pm <task>[:<tag>][:<sub_tag>] [-v] [-vv]
```
## Presets locations
- default presets [`project_manager/preset/default/*.toml`](./project_manager/preset/default)
- user preset `$HOME/.pm.toml`
- project preset `./.pm.toml`
![pm lint](docs/Screenshot_1.png "pm lint")
![pm build](docs/Screenshot_2.png "pm build")
## Preset format
@@ -39,3 +46,9 @@ commands = ["<command>"]
### Examples
[Default presets](./project_manager/preset/default)
## Presets locations
- default presets [`project_manager/preset/default/*.toml`](./project_manager/preset/default)
- user preset `$HOME/.pm.toml`
- project preset `./.pm.toml`
+36
View File
@@ -0,0 +1,36 @@
name: "Project Manager"
inputs:
python-version:
default: "3.14"
setup-python:
default: false
venv-version:
default: "1"
runs:
using: "composite"
steps:
- id: setup-python
uses: actions/setup-python@v7
with:
python-version: ${{ inputs.python-version }}
cache: "pip"
- run: |
pip install project-manager \
--index-url https://git.shmyga.ru/api/packages/InfernalGames/pypi/simple/ \
--extra-index-url https://pypi.org/simple
- run: pm setup:config -vv
- if: inputs.setup-python
run: pip install poetry
- id: cache-venv
if: inputs.setup-python
uses: actions/cache@v6
with:
path: .venv
key: venv-${{ runner.os }}-python-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('**/poetry.lock') }}-v${{ inputs.venv-version }}
- if: ${{ inputs.setup-python && steps.cache-venv.outputs.cache-hit != 'true' }}
run: pm setup:python -vv
Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

+24 -5
View File
@@ -1,27 +1,34 @@
import json
import os
import re
import tomllib
from pathlib import Path
from typing import Any
import dotenv
from jinja2 import Environment
from ..preset.model import Variables
from .util import get_environ
def do_bool(data: str | None) -> bool:
return not (data is None or data.lower() in ["false", "null", "none", "0", ""])
if data is None:
return False
if not isinstance(data, str):
data = str(data)
return data.lower() not in ["false", "null", "none", "0", ""]
def do_env(key: str, default: str | None = None) -> str | None:
return os.environ.get(key=key.upper(), default=default)
def do_not(value: Any | None) -> bool:
return not do_bool(str(value))
def _build_env(basepath: Path) -> Environment:
env = Environment()
def do_file(filename: str) -> Path | None:
if not filename:
return None
result = Path(filename)
if not result.is_absolute():
result = basepath / result
@@ -64,6 +71,11 @@ def _build_env(basepath: Path) -> Environment:
keys = list(data.keys())
return keys[keyindex]
env_data = get_environ(basepath)
def do_env(key: str, default: str | None = None) -> str | None:
return env_data.get(key.upper(), default)
env.filters["file"] = do_file
env.filters["read"] = do_read
env.filters["get"] = do_get
@@ -71,6 +83,7 @@ def _build_env(basepath: Path) -> Environment:
env.filters["key"] = do_key
env.filters["bool"] = do_bool
env.filters["env"] = do_env
env.filters["not"] = do_not
return env
@@ -81,7 +94,13 @@ class PathTemplate:
def update_variables(self, templates: Variables, variables: Variables):
for k, v in templates.items():
variables[k] = self._env.from_string(v).render(**variables)
if isinstance(v, dict):
variables[k] = {
kk: self._env.from_string(vv).render(**variables)
for kk, vv in v.items()
}
else:
variables[k] = self._env.from_string(v).render(**variables)
def fill_template(self, template: str, variables: Variables) -> str:
return self._env.from_string(template).render(**variables)
+13
View File
@@ -1,3 +1,9 @@
import os
from pathlib import Path
import dotenv
def merge_dicts(target: dict, enhancer: dict) -> dict:
for key, val in enhancer.items():
if key not in target:
@@ -9,3 +15,10 @@ def merge_dicts(target: dict, enhancer: dict) -> dict:
else:
target[key] = val
return target
def get_environ(path: Path) -> dict[str, str]:
result = os.environ.copy()
if (env_file := path / ".env").exists():
result.update(dotenv.dotenv_values(env_file))
return result
+4 -4
View File
@@ -85,20 +85,20 @@ class Entry:
for command in task.commands:
self._logger.debug(" %s", command)
def run_task(self, key: PartialTaskKey) -> bool:
def run_task(self, key: PartialTaskKey) -> int:
if key.name == "show":
self.show()
return True
runned = False
c = 0
for task in self._tasks:
if key == task.key:
self._logger.info("🔵 %s %s", task.key, self)
runned = True
for command in task.commands:
CommandExecutor(command).run(
(task.cwd and Path(task.cwd)) or self._basepath
)
return runned
c += 1
return c
def __repr__(self):
path = self._basepath.relative_to(self._roopath.parent)
+1 -1
View File
@@ -9,7 +9,7 @@ from .entry import Entry
class EntryResolver:
def __init__(self, detector: EntryDetector, ignore: PathSpec, max_depth: int = 4):
def __init__(self, detector: EntryDetector, ignore: PathSpec, max_depth: int = 2):
self._detector = detector
self._ignore = ignore
self._max_depth = max_depth
+5 -5
View File
@@ -1,6 +1,6 @@
import logging
from collections import Counter
from enum import StrEnum
from enum import StrEnum, auto
from pathlib import Path
from typing import Iterable
@@ -14,8 +14,8 @@ logger = logging.getLogger("project")
class ResultType(StrEnum):
SUCCESS = "success"
FAILED = "failed"
SUCCESS = auto()
FAILED = auto()
RESULT_ICONS = {
@@ -40,8 +40,8 @@ class ProjectManager:
counter = Counter()
for entry in self._iter_entries():
try:
if entry.run_task(key):
counter[ResultType.SUCCESS] += 1
if count := entry.run_task(key):
counter[ResultType.SUCCESS] += count
except CommandError as error:
self._logger.error("%s\n%s", entry, error.output)
counter[ResultType.FAILED] += 1
@@ -0,0 +1,16 @@
[presets.config.tasks.setup.env]
priority = 1
conditions = ["{{ ('.env-base' | file) and ('.env' | file | not ) }}"]
commands = ["cp .env-base .env"]
[presets.config.tasks.setup.env2]
priority = 1
conditions = ["{{ ('.env.default' | file) and ('.env' | file | not ) }}"]
commands = ["cp .env.default .env"]
[presets.config.tasks.setup.json]
priority = 1
conditions = [
"{{ ('config.example.json' | file) and ('config.json' | file | not ) }}",
]
commands = ["cp config.example.json config.json"]
+18 -6
View File
@@ -1,15 +1,17 @@
[presets.docker]
conditions = ["{{ 'Dockerfile' | file }}"]
variables.docker_name = "{{ 'pyproject.toml' | read | get('project.name') or 'package.json' | read | get('name') }}"
variables.docker_version = "{{ 'pyproject.toml' | read | get('project.version') or 'package.json' | read | get('version') }}"
variables.docker_repo = "{{ '.env' | read | get('DOCKER_REPO') }}"
variables.docker_group = "{{ '.env' | read | get('DOCKER_GROUP') }}"
variables.docker_name = "{{ project_name }}"
variables.docker_version = "{{ project_version }}"
variables.docker_repo = "{{ 'DOCKER_REPO' | env }}"
variables.docker_group = "{{ 'DOCKER_GROUP' | env }}"
variables.docker_image = "{% if docker_group | bool %}{{ docker_group }}/{% endif %}{{ docker_name }}"
variables.docker_tag = "{{ '.env' | read | get('DOCKER_TAG') or 'latest' }}"
variables.docker_tag = "{{ 'DOCKER_TAG' | env('latest') }}"
variables.docker_target = "{{ 'DOCKER_TARGET' | env }}"
variables.docker_target_arg = "{{ ('--target ' + docker_target) if docker_target else '' }}"
# build
[presets.docker.tasks.build.default]
commands = ["docker build -t {{ docker_image }} . -f Dockerfile"]
commands = ["docker build {{ docker_target_arg }} -t {{ docker_image }} . -f Dockerfile"]
# publish version
[presets.docker.tasks.publish.version]
@@ -34,3 +36,13 @@ commands = [
"mkdir -p {{ docker_output }}",
"docker save --output {{ docker_output }}/{{ docker_name }}-{{ docker_version }}.tar {{ docker_image }}",
]
# run
[presets.docker.tasks.run.compose]
conditions = ["{{ 'docker-compose.yaml' | file }}"]
commands = ["docker compose -f docker-compose.yaml up"]
# develop
[presets.docker.tasks.develop.compose]
conditions = ["{{ 'docker-compose-develop.yaml' | file }}"]
commands = ["docker compose -f docker-compose-develop.yaml up --build --watch"]
+24
View File
@@ -0,0 +1,24 @@
[presets.gitea]
variables.publish_repo = "{{ 'PUBLISH_REPO' | env }}"
variables.publish_repo_path = "{{ publish_repo }}/generic/{{ project_name }}/{{ project_version }}"
variables.publish_user = "{{ 'PUBLISH_USER' | env }}"
variables.publish_password = "{{ 'PUBLISH_PASSWORD' | env }}"
variables.publish_auth = "{{ publish_user }}:{{ publish_password }}"
variables.publish_artefacts = {}
[presets.gitea.tasks.publish."{{ publish_artefacts | tojson }}"]
conditions = ["{{ item | file }}"]
variables.artefact_file = "{{ item }}"
variables.artefact_name = "{{ (artefact_file | file).name }}"
commands = [
"curl --user \"{{ publish_auth }}\" -X DELETE \"{{ publish_repo_path }}/{{ artefact_name }}\"",
"curl --user \"{{ publish_auth }}\" --fail --upload-file \"{{ artefact_file }}\" \"{{ publish_repo_path }}/{{ artefact_name }}\"",
]
[presets.gitea.tasks.publish.debian_repo]
conditions = ["{{ publish_artefacts.debian | file }}"]
variables.artefact_file = "{{ publish_artefacts.debian }}"
commands = [
"curl --user \"{{ publish_auth }}\" -X DELETE \"{{ publish_repo }}/debian/pool/noble/main/{{ project_name }}/{{ project_version }}/all\"",
"curl --user \"{{ publish_auth }}\" --fail --upload-file \"{{ artefact_file }}\" \"{{ publish_repo }}/debian/pool/noble/main/upload\"",
]
@@ -1,5 +1,7 @@
[presets.'*']
priority = 1000
conditions = ["{{ true }}"]
variables.project_name = "{{ 'pyproject.toml' | read | get('project.name') or 'package.json' | read | get('name') }}"
variables.project_version = "{{ 'pyproject.toml' | read | get('project.version') or 'package.json' | read | get('version') }}"
variables.version = "{{ 'version' | env }}"
tasks = {}
+8 -6
View File
@@ -1,30 +1,32 @@
[presets.nodejs]
conditions = ["{{ 'package.json' | file }}"]
variables.use_nvm = "{{ (((('home' | env) + '/.nvm/nvm.sh') | file) and ('.nvmrc' | file)) | bool }}"
variables.nvm = "{{ '. ~/.nvm/nvm.sh && nvm use && ' if use_nvm | bool else '' }}"
# install
[presets.nodejs.tasks.setup.npm]
conditions = ["{{ 'package-lock.json' | file }}"]
commands = ["npm install-clean"]
commands = ["{{ nvm }}npm install-clean"]
# install legacy
[presets.nodejs.tasks.setup.npm-legacy]
conditions = ["{{ not ('package-lock.json' | file) }}"]
commands = ["npm install"]
commands = ["{{ nvm }}npm install"]
# test
[presets.nodejs.tasks.test.jest]
conditions = ["{{ 'node_modules/jest' | file }}"]
commands = ["npx jest"]
commands = ["{{ nvm }}npx jest"]
# build
[presets.nodejs.tasks.build.npm]
conditions = ["{{ 'package.json' | read | get('scripts.build') }}"]
commands = ["npm run build"]
commands = ["{{ nvm }}npm run build"]
# publish
[presets.nodejs.tasks.publish.npm]
conditions = ["{{ 'package.json' | read | get('scripts.publish') }}"]
commands = ["npm run publish"]
commands = ["{{ nvm }}npm run publish"]
# clean
[presets.nodejs.tasks.clean.node_modules]
@@ -34,4 +36,4 @@ commands = ["rm -rf node_modules"]
# version
[presets.nodejs.tasks.version.npm]
conditions = ["{{ version }}"]
commands = ["npm version {{ version }} --allow-same-version"]
commands = ["{{ nvm }}npm version {{ version }} --allow-same-version"]
+6 -1
View File
@@ -6,7 +6,12 @@ variables.python_poetry = "{{ 'pyproject.toml' | read | get('build-system.build-
[presets.python.tasks.setup.poetry]
conditions = ["{{ python_poetry }}"]
variables.python_version = "{{ 'pyproject.toml' | read | get('project.requires-python') | search('(\\d+.\\d+)') }}"
commands = ["poetry env use {{ python_version }}", "poetry install"]
commands = ["poetry env use {{ python_version }}", "poetry install --all-extras"]
# playwright
[presets.python.tasks.setup.playwright]
conditions = ["{{ '.venv/bin/playwright' | file }}"]
commands = [".venv/bin/playwright install chromium --with-deps --only-shell"]
# build
[presets.python.tasks.build.poetry]
+36 -17
View File
@@ -1,3 +1,4 @@
import json
import operator
from os import environ
from pathlib import Path
@@ -14,9 +15,14 @@ class PresetEntryDetector(EntryDetector):
def __init__(self, preset_loader_config: PresetLoaderConfig | None = None):
self._preset_loader_config = preset_loader_config
# pylint:disable=too-many-locals,too-many-nested-blocks,too-many-branches
def detect_entry(self, basepath: Path, rootpath: Path) -> Entry:
template = PathTemplate(basepath)
preset = PresetLoader(basepath, self._preset_loader_config).load()
if preset.modules:
modules = [Path(item).resolve() for item in preset.modules]
if basepath not in modules:
return ENTRY_SKIP
if preset.skip:
return ENTRY_SKIP
tasks = []
@@ -25,7 +31,10 @@ class PresetEntryDetector(EntryDetector):
preset.presets.items(), key=lambda item: item[1].priority, reverse=True
)
for tag, preset in presets:
preset_variables: Variables = {**global_variables, "entry_path": basepath}
preset_variables: Variables = {
**global_variables,
"entry_path": str(basepath),
}
if preset.variables:
if tag == "*":
template.update_variables(preset.variables, global_variables)
@@ -36,22 +45,32 @@ class PresetEntryDetector(EntryDetector):
continue
for task_name, task_pack in preset.tasks.items():
for sub_tag, task_preset in task_pack.items():
if not task_preset.conditions or template.check_conditions(
task_preset.conditions, preset_variables
):
variables = preset_variables.copy()
if task_preset.variables:
template.update_variables(task_preset.variables, variables)
task = Task(
key=TaskKey(task_name, tag, sub_tag),
commands=[
template.fill_template(command, variables)
for command in task_preset.commands
],
priority=task_preset.priority,
cwd=task_preset.cwd,
)
tasks.append(task)
sub_tags = template.fill_template(sub_tag, preset_variables)
try:
sub_tags = json.loads(sub_tags)
except ValueError:
sub_tags = {sub_tags: None}
for i_sub_tag, i_sub_tag_value in sub_tags.items():
if i_sub_tag_value:
preset_variables["item"] = i_sub_tag_value
if not task_preset.conditions or template.check_conditions(
task_preset.conditions, preset_variables
):
variables = preset_variables.copy()
if task_preset.variables:
template.update_variables(
task_preset.variables, variables
)
task = Task(
key=TaskKey(task_name, tag, i_sub_tag),
commands=[
template.fill_template(command, variables)
for command in task_preset.commands
],
priority=task_preset.priority,
cwd=task_preset.cwd,
)
tasks.append(task)
if tasks:
tasks = sorted(tasks, key=operator.attrgetter("priority"), reverse=True)
return Entry(tasks, basepath, rootpath)
+4 -1
View File
@@ -1,6 +1,8 @@
from typing import Any
from pydantic import BaseModel
Variables = dict[str, str]
Variables = dict[str, Any]
class TaskPreset(BaseModel):
@@ -20,4 +22,5 @@ class EntryPreset(BaseModel):
class Preset(BaseModel):
skip: bool | None = None
modules: list[str] | None = None
presets: dict[str, EntryPreset]
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "project-manager"
description = ""
version = "0.2.0"
version = "0.3.1"
readme = "README.md"
authors = [{ name = "shmyga", email = "shmyga.z@gmail.com" }]
requires-python = ">=3.14,<4.0"