diff --git a/changelog.d/20260312_154519_kolo.is.main_cli.md b/changelog.d/20260312_154519_kolo.is.main_cli.md new file mode 100644 index 0000000..6988d25 --- /dev/null +++ b/changelog.d/20260312_154519_kolo.is.main_cli.md @@ -0,0 +1,35 @@ + + +### Added + +- A cli module that implements the ability to launch applications on Argenta, run application benchmarks on Argenta, create a boilerplate for new projects, and much more. + + + + + diff --git a/justfile b/justfile index b13b6a7..92c2a5b 100644 --- a/justfile +++ b/justfile @@ -1,36 +1,57 @@ set windows-shell := ["powershell.exe", "-NoLogo", "-Command"] set shell := ["bash", "-c"] -# Вывести список всех рецептов +# List all available recipes default: @just --list -# Запустить тесты через pytest +# ── Testing ─────────────────────────────────────────────────────────────────── + +# Run tests via pytest tests: python -m pytest tests -# Запустить тесты с отчетом о покрытии +# Run tests with coverage report tests-cov: python -m pytest --cov=argenta tests -# Запустить тесты с отчетом о покрытии с html репортом +# Run tests with coverage HTML report tests-cov-html: python -m pytest --cov=argenta tests --cov-report=html -# Отформатировать код (Ruff + isort) +# ── Code quality ────────────────────────────────────────────────────────────── + +# Format code (Ruff + isort) format: python -m ruff format ./src python -m isort ./src -# Проверить типы через mypy (strict) +# Check types via mypy (strict) mypy: python -m mypy -p argenta --strict -# Проверить стиль через wemake-python-styleguide +# Check style via wemake-python-styleguide wps: python -m flake8 --format=wemake ./src -# Запустить линтер Ruff +# Run Ruff linter ruff: python -m ruff check ./src +# Run all checks (format, mypy, ruff, wps) +check-format: format mypy ruff wps + +# ── Changelog (scriv) ───────────────────────────────────────────────────────── + +# Create a new changelog fragment and open it in $EDITOR +frag: + if (-not (Test-Path "./changelog.d")) { New-Item -ItemType Directory -Path "./changelog.d" } + scriv create --add + +# Preview collected changelog without writing anything +changelog-preview: + scriv collect --dry-run + +# Collect fragments into CHANGELOG.md for release (usage: just release 1.2.3) +release version: + scriv collect --version {{ version }} --add diff --git a/src/argenta/_cli/commands/routes.py b/src/argenta/_cli/commands/routes.py new file mode 100644 index 0000000..e69de29 diff --git a/src/argenta/_cli/commands/run.py b/src/argenta/_cli/commands/run.py index 8eed55e..220638e 100644 --- a/src/argenta/_cli/commands/run.py +++ b/src/argenta/_cli/commands/run.py @@ -34,7 +34,7 @@ def import_from_string(import_str: str) -> Any: def run_handler(entry_point: str) -> None: - os.environ["RUN_AS_ARGENTA_APPLICATION"] = "1" + os.environ["RUN_FROM_ARGENTA_RUNNER"] = "1" if str(Path.cwd()) not in sys.path: sys.path.insert(0, str(Path.cwd())) diff --git a/src/argenta/_cli/infrastructure/entrypoint_resolver.py b/src/argenta/_cli/infrastructure/entrypoint_resolver.py new file mode 100644 index 0000000..24a9f13 --- /dev/null +++ b/src/argenta/_cli/infrastructure/entrypoint_resolver.py @@ -0,0 +1,117 @@ +from dataclasses import dataclass +import inspect +import importlib.util +from pathlib import Path +from typing import Callable, Protocol, cast, overload + +from argenta import App + + +class ResolverError(Exception): + def __init__(self, entrypoint_as_repr: str) -> None: + self.entrypoint_as_repr = entrypoint_as_repr + +class ResolveFromStringError(ResolverError): + pass + +class EntrypointNotCallableError(ResolverError): + def __str__(self): + return f'Entrypoint {self.entrypoint_as_repr} is not callable' + +class CallableEntrypointNotMatchRequiredSignatureError(ResolverError): + def __str__(self) -> str: + return f'Callable entrypoint {self.entrypoint_as_repr} not match with required signature Callable[[], ...]' + +class EntrypointNotAppInstanceError(ResolverError): + def __str__(self): + return f'Entrypoint {self.entrypoint_as_repr} is not instance of App' + + +class EntryPoint[T](Protocol): + @property + def raw_path(self) -> str: ... + @property + def instance_object(self) -> T: ... + + +@dataclass(frozen=True, slots=True) +class CallableEntryPoint: + raw_path: str + instance_object: Callable[[], None] + + +@dataclass(frozen=True, slots=True) +class EntryPointAsApp: + raw_path: str + instance_object: App + + +class EntrypointResolver: + def __init__(self, path_to_entrypoint: str): + self._path_to_entrypoint = path_to_entrypoint + + @overload + def parse_entrypoint_with_type( + self, entrypoint_type: type[CallableEntryPoint] + ) -> EntryPoint[Callable[[], None]]: ... + @overload + def parse_entrypoint_with_type( + self, entrypoint_type: type[EntryPointAsApp] + ) -> EntryPoint[App]: ... + + def parse_entrypoint_with_type( + self, entrypoint_type: type[CallableEntryPoint] | type[EntryPointAsApp] + ) -> EntryPoint[Callable[[], None]] | EntryPoint[App]: + if entrypoint_type is CallableEntryPoint: + return self._parse_callable_entrypoint() + elif entrypoint_type is EntryPointAsApp: + return self._parse_entrypoint_as_app() + raise NotImplementedError + + def _parse_callable_entrypoint(self) -> CallableEntryPoint: + resolved_entrypoint = self._resolve_from_string() + instance_object = resolved_entrypoint[1] + if not callable(instance_object): + raise EntrypointNotCallableError(repr(instance_object)) + instance_object_signature = inspect.signature(instance_object) + required_params = instance_object_signature.parameters + + if required_params: + raise CallableEntrypointNotMatchRequiredSignatureError(repr(instance_object)) + + instance_object = cast(Callable[[], None], instance_object) + return CallableEntryPoint(raw_path=resolved_entrypoint[0], instance_object=instance_object) + + def _parse_entrypoint_as_app(self) -> EntryPointAsApp: + resolved_entrypoint = self._resolve_from_string() + instance_object = resolved_entrypoint[1] + if not isinstance(instance_object, App): + raise EntrypointNotAppInstanceError(repr(instance_object)) + + return EntryPointAsApp(raw_path=resolved_entrypoint[0], instance_object=instance_object) + + def _resolve_from_string(self) -> tuple[str, object]: + file_path, _, attr_name = self._path_to_entrypoint.partition(":") + + if not file_path or not attr_name: + raise ResolveFromStringError( + f'"{self._path_to_entrypoint}" must be in format ":"' + ) + + path = Path(file_path).resolve() + if not path.exists(): + raise ResolveFromStringError(f'File "{file_path}" not found') + + spec = importlib.util.spec_from_file_location(path.stem, path) + if spec is None or spec.loader is None: + raise ResolveFromStringError(f'Cannot load module from "{file_path}"') + + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + try: + instance = getattr(module, attr_name) + except AttributeError: + raise ResolveFromStringError(f'"{attr_name}" not found in "{file_path}"') + + return file_path, instance diff --git a/src/argenta/orchestrator/entity.py b/src/argenta/orchestrator/entity.py index 0832c51..39175e2 100644 --- a/src/argenta/orchestrator/entity.py +++ b/src/argenta/orchestrator/entity.py @@ -23,7 +23,7 @@ class Orchestrator: :param arg_parser: Cmd argument parser and configurator at startup :return: None """ - self._arg_parser: ArgParser | None = arg_parser if not os.getenv('RUN_AS_ARGENTA_APPLICATION') else None + self._arg_parser: ArgParser | None = arg_parser if not os.getenv('RUN_FROM_ARGENTA_RUNNER') else None self._custom_providers: list[Provider] = custom_providers or [] self._auto_inject_handlers: bool = auto_inject_handlers