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 # Project Manager
Automatically detects modules and executes commands
Supported types:
- python (poetry)
- nodejs (npm)
- docker
## Install ## Install
```bash ```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 ## Usage
@@ -14,17 +22,16 @@ pipx install project-manager -i https://git.shmyga.ru/InfernalGames/project-mana
pm [-v] [-vv] pm [-v] [-vv]
``` ```
![pm show](docs/Screenshot_0.png "pm show")
### Run task ### Run task
```bash ```bash
pm <task>[:<tag>] [-v] [-vv] pm <task>[:<tag>][:<sub_tag>] [-v] [-vv]
``` ```
## Presets locations ![pm lint](docs/Screenshot_1.png "pm lint")
![pm build](docs/Screenshot_2.png "pm build")
- default presets [`project_manager/preset/default/*.toml`](./project_manager/preset/default)
- user preset `$HOME/.pm.toml`
- project preset `./.pm.toml`
## Preset format ## Preset format
@@ -39,3 +46,9 @@ commands = ["<command>"]
### Examples ### Examples
[Default presets](./project_manager/preset/default) [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 json
import os
import re import re
import tomllib import tomllib
from pathlib import Path from pathlib import Path
from typing import Any
import dotenv import dotenv
from jinja2 import Environment from jinja2 import Environment
from ..preset.model import Variables from ..preset.model import Variables
from .util import get_environ
def do_bool(data: str | None) -> bool: 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: def do_not(value: Any | None) -> bool:
return os.environ.get(key=key.upper(), default=default) return not do_bool(str(value))
def _build_env(basepath: Path) -> Environment: def _build_env(basepath: Path) -> Environment:
env = Environment() env = Environment()
def do_file(filename: str) -> Path | None: def do_file(filename: str) -> Path | None:
if not filename:
return None
result = Path(filename) result = Path(filename)
if not result.is_absolute(): if not result.is_absolute():
result = basepath / result result = basepath / result
@@ -64,6 +71,11 @@ def _build_env(basepath: Path) -> Environment:
keys = list(data.keys()) keys = list(data.keys())
return keys[keyindex] 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["file"] = do_file
env.filters["read"] = do_read env.filters["read"] = do_read
env.filters["get"] = do_get env.filters["get"] = do_get
@@ -71,6 +83,7 @@ def _build_env(basepath: Path) -> Environment:
env.filters["key"] = do_key env.filters["key"] = do_key
env.filters["bool"] = do_bool env.filters["bool"] = do_bool
env.filters["env"] = do_env env.filters["env"] = do_env
env.filters["not"] = do_not
return env return env
@@ -81,7 +94,13 @@ class PathTemplate:
def update_variables(self, templates: Variables, variables: Variables): def update_variables(self, templates: Variables, variables: Variables):
for k, v in templates.items(): 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: def fill_template(self, template: str, variables: Variables) -> str:
return self._env.from_string(template).render(**variables) 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: def merge_dicts(target: dict, enhancer: dict) -> dict:
for key, val in enhancer.items(): for key, val in enhancer.items():
if key not in target: if key not in target:
@@ -9,3 +15,10 @@ def merge_dicts(target: dict, enhancer: dict) -> dict:
else: else:
target[key] = val target[key] = val
return target 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: for command in task.commands:
self._logger.debug(" %s", command) self._logger.debug(" %s", command)
def run_task(self, key: PartialTaskKey) -> bool: def run_task(self, key: PartialTaskKey) -> int:
if key.name == "show": if key.name == "show":
self.show() self.show()
return True return True
runned = False c = 0
for task in self._tasks: for task in self._tasks:
if key == task.key: if key == task.key:
self._logger.info("🔵 %s %s", task.key, self) self._logger.info("🔵 %s %s", task.key, self)
runned = True
for command in task.commands: for command in task.commands:
CommandExecutor(command).run( CommandExecutor(command).run(
(task.cwd and Path(task.cwd)) or self._basepath (task.cwd and Path(task.cwd)) or self._basepath
) )
return runned c += 1
return c
def __repr__(self): def __repr__(self):
path = self._basepath.relative_to(self._roopath.parent) path = self._basepath.relative_to(self._roopath.parent)
+1 -1
View File
@@ -9,7 +9,7 @@ from .entry import Entry
class EntryResolver: 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._detector = detector
self._ignore = ignore self._ignore = ignore
self._max_depth = max_depth self._max_depth = max_depth
+5 -5
View File
@@ -1,6 +1,6 @@
import logging import logging
from collections import Counter from collections import Counter
from enum import StrEnum from enum import StrEnum, auto
from pathlib import Path from pathlib import Path
from typing import Iterable from typing import Iterable
@@ -14,8 +14,8 @@ logger = logging.getLogger("project")
class ResultType(StrEnum): class ResultType(StrEnum):
SUCCESS = "success" SUCCESS = auto()
FAILED = "failed" FAILED = auto()
RESULT_ICONS = { RESULT_ICONS = {
@@ -40,8 +40,8 @@ class ProjectManager:
counter = Counter() counter = Counter()
for entry in self._iter_entries(): for entry in self._iter_entries():
try: try:
if entry.run_task(key): if count := entry.run_task(key):
counter[ResultType.SUCCESS] += 1 counter[ResultType.SUCCESS] += count
except CommandError as error: except CommandError as error:
self._logger.error("%s\n%s", entry, error.output) self._logger.error("%s\n%s", entry, error.output)
counter[ResultType.FAILED] += 1 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] [presets.docker]
conditions = ["{{ 'Dockerfile' | file }}"] conditions = ["{{ 'Dockerfile' | file }}"]
variables.docker_name = "{{ 'pyproject.toml' | read | get('project.name') or 'package.json' | read | get('name') }}" variables.docker_name = "{{ project_name }}"
variables.docker_version = "{{ 'pyproject.toml' | read | get('project.version') or 'package.json' | read | get('version') }}" variables.docker_version = "{{ project_version }}"
variables.docker_repo = "{{ '.env' | read | get('DOCKER_REPO') }}" variables.docker_repo = "{{ 'DOCKER_REPO' | env }}"
variables.docker_group = "{{ '.env' | read | get('DOCKER_GROUP') }}" variables.docker_group = "{{ 'DOCKER_GROUP' | env }}"
variables.docker_image = "{% if docker_group | bool %}{{ docker_group }}/{% endif %}{{ docker_name }}" 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 # build
[presets.docker.tasks.build.default] [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 # publish version
[presets.docker.tasks.publish.version] [presets.docker.tasks.publish.version]
@@ -34,3 +36,13 @@ commands = [
"mkdir -p {{ docker_output }}", "mkdir -p {{ docker_output }}",
"docker save --output {{ docker_output }}/{{ docker_name }}-{{ docker_version }}.tar {{ docker_image }}", "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.'*'] [presets.'*']
priority = 1000 priority = 1000
conditions = ["{{ true }}"] 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 }}" variables.version = "{{ 'version' | env }}"
tasks = {} tasks = {}
+8 -6
View File
@@ -1,30 +1,32 @@
[presets.nodejs] [presets.nodejs]
conditions = ["{{ 'package.json' | file }}"] 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 # install
[presets.nodejs.tasks.setup.npm] [presets.nodejs.tasks.setup.npm]
conditions = ["{{ 'package-lock.json' | file }}"] conditions = ["{{ 'package-lock.json' | file }}"]
commands = ["npm install-clean"] commands = ["{{ nvm }}npm install-clean"]
# install legacy # install legacy
[presets.nodejs.tasks.setup.npm-legacy] [presets.nodejs.tasks.setup.npm-legacy]
conditions = ["{{ not ('package-lock.json' | file) }}"] conditions = ["{{ not ('package-lock.json' | file) }}"]
commands = ["npm install"] commands = ["{{ nvm }}npm install"]
# test # test
[presets.nodejs.tasks.test.jest] [presets.nodejs.tasks.test.jest]
conditions = ["{{ 'node_modules/jest' | file }}"] conditions = ["{{ 'node_modules/jest' | file }}"]
commands = ["npx jest"] commands = ["{{ nvm }}npx jest"]
# build # build
[presets.nodejs.tasks.build.npm] [presets.nodejs.tasks.build.npm]
conditions = ["{{ 'package.json' | read | get('scripts.build') }}"] conditions = ["{{ 'package.json' | read | get('scripts.build') }}"]
commands = ["npm run build"] commands = ["{{ nvm }}npm run build"]
# publish # publish
[presets.nodejs.tasks.publish.npm] [presets.nodejs.tasks.publish.npm]
conditions = ["{{ 'package.json' | read | get('scripts.publish') }}"] conditions = ["{{ 'package.json' | read | get('scripts.publish') }}"]
commands = ["npm run publish"] commands = ["{{ nvm }}npm run publish"]
# clean # clean
[presets.nodejs.tasks.clean.node_modules] [presets.nodejs.tasks.clean.node_modules]
@@ -34,4 +36,4 @@ commands = ["rm -rf node_modules"]
# version # version
[presets.nodejs.tasks.version.npm] [presets.nodejs.tasks.version.npm]
conditions = ["{{ version }}"] 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] [presets.python.tasks.setup.poetry]
conditions = ["{{ python_poetry }}"] conditions = ["{{ python_poetry }}"]
variables.python_version = "{{ 'pyproject.toml' | read | get('project.requires-python') | search('(\\d+.\\d+)') }}" 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 # build
[presets.python.tasks.build.poetry] [presets.python.tasks.build.poetry]
+36 -17
View File
@@ -1,3 +1,4 @@
import json
import operator import operator
from os import environ from os import environ
from pathlib import Path from pathlib import Path
@@ -14,9 +15,14 @@ class PresetEntryDetector(EntryDetector):
def __init__(self, preset_loader_config: PresetLoaderConfig | None = None): def __init__(self, preset_loader_config: PresetLoaderConfig | None = None):
self._preset_loader_config = preset_loader_config 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: def detect_entry(self, basepath: Path, rootpath: Path) -> Entry:
template = PathTemplate(basepath) template = PathTemplate(basepath)
preset = PresetLoader(basepath, self._preset_loader_config).load() 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: if preset.skip:
return ENTRY_SKIP return ENTRY_SKIP
tasks = [] tasks = []
@@ -25,7 +31,10 @@ class PresetEntryDetector(EntryDetector):
preset.presets.items(), key=lambda item: item[1].priority, reverse=True preset.presets.items(), key=lambda item: item[1].priority, reverse=True
) )
for tag, preset in presets: 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 preset.variables:
if tag == "*": if tag == "*":
template.update_variables(preset.variables, global_variables) template.update_variables(preset.variables, global_variables)
@@ -36,22 +45,32 @@ class PresetEntryDetector(EntryDetector):
continue continue
for task_name, task_pack in preset.tasks.items(): for task_name, task_pack in preset.tasks.items():
for sub_tag, task_preset in task_pack.items(): for sub_tag, task_preset in task_pack.items():
if not task_preset.conditions or template.check_conditions( sub_tags = template.fill_template(sub_tag, preset_variables)
task_preset.conditions, preset_variables try:
): sub_tags = json.loads(sub_tags)
variables = preset_variables.copy() except ValueError:
if task_preset.variables: sub_tags = {sub_tags: None}
template.update_variables(task_preset.variables, variables) for i_sub_tag, i_sub_tag_value in sub_tags.items():
task = Task( if i_sub_tag_value:
key=TaskKey(task_name, tag, sub_tag), preset_variables["item"] = i_sub_tag_value
commands=[ if not task_preset.conditions or template.check_conditions(
template.fill_template(command, variables) task_preset.conditions, preset_variables
for command in task_preset.commands ):
], variables = preset_variables.copy()
priority=task_preset.priority, if task_preset.variables:
cwd=task_preset.cwd, template.update_variables(
) task_preset.variables, variables
tasks.append(task) )
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: if tasks:
tasks = sorted(tasks, key=operator.attrgetter("priority"), reverse=True) tasks = sorted(tasks, key=operator.attrgetter("priority"), reverse=True)
return Entry(tasks, basepath, rootpath) return Entry(tasks, basepath, rootpath)
+4 -1
View File
@@ -1,6 +1,8 @@
from typing import Any
from pydantic import BaseModel from pydantic import BaseModel
Variables = dict[str, str] Variables = dict[str, Any]
class TaskPreset(BaseModel): class TaskPreset(BaseModel):
@@ -20,4 +22,5 @@ class EntryPreset(BaseModel):
class Preset(BaseModel): class Preset(BaseModel):
skip: bool | None = None skip: bool | None = None
modules: list[str] | None = None
presets: dict[str, EntryPreset] presets: dict[str, EntryPreset]
+1 -1
View File
@@ -1,7 +1,7 @@
[project] [project]
name = "project-manager" name = "project-manager"
description = "" description = ""
version = "0.2.0" version = "0.3.1"
readme = "README.md" readme = "README.md"
authors = [{ name = "shmyga", email = "shmyga.z@gmail.com" }] authors = [{ name = "shmyga", email = "shmyga.z@gmail.com" }]
requires-python = ">=3.14,<4.0" requires-python = ">=3.14,<4.0"