diff --git a/README.md b/README.md index 9f50b4260..805263aa3 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![PyPI version](https://img.shields.io/pypi/pyversions/poethepoet.svg)](https://pypi.org/project/poethepoet/) [![PyPI version](https://img.shields.io/pypi/v/poethepoet.svg)](https://pypi.org/project/poethepoet/) -[![PyPI version](https://img.shields.io/pypi/dw/poethepoet.svg)](https://pypistats.org/packages/poethepoet) +[![PyPI version](https://img.shields.io/pypi/dm/poethepoet.svg)](https://pypistats.org/packages/poethepoet) [![PyPI version](https://img.shields.io/pypi/l/ansicolortags.svg)](https://github.com/nat-n/poethepoet/blob/doc/init-sphinx/LICENSE) **A batteries included task runner that works well with [poetry](https://python-poetry.org/).** @@ -15,8 +15,7 @@ ## Features - -- ✅ Straight forward [declaration of project tasks in your pyproject.toml](https://poethepoet.natn.io/tasks/index.html) +- ✅ Straight forward [declaration of project tasks in your pyproject.toml](https://poethepoet.natn.io/tasks/index.html) (or [poe_tasks.toml](https://poethepoet.natn.io/guides/without_poetry.html#usage-without-pyproject-toml)) - ✅ Tasks are run in poetry's virtualenv ([or another env](https://poethepoet.natn.io/index.html#usage-without-poetry) you specify) @@ -40,6 +39,7 @@ - ✅ Can be [used as a library](https://poethepoet.natn.io/guides/library_guide.html) to embed in other tools +- ✅ Also works fine [without poetry](https://poethepoet.natn.io/guides/without_poetry.html) ## Quick start diff --git a/docs/conf.py b/docs/conf.py index b8a7911ef..bef4132ba 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -44,6 +44,17 @@ .. |V| unicode:: ✅ 0xA0 0xA0 :trim: +.. |pipx_link| raw:: html + + pipx + +.. |poetry_link| raw:: html + + poetry + +.. |pep518_link| raw:: html + + PEP 518 """ linkcheck_ignore = [ diff --git a/docs/guides/include_guide.rst b/docs/guides/include_guide.rst index 0f980c76d..a7ecf3468 100644 --- a/docs/guides/include_guide.rst +++ b/docs/guides/include_guide.rst @@ -1,7 +1,11 @@ Loading tasks from another file =============================== -There are some scenarios where one might wish to define tasks outside of pyproject.toml, or to collect tasks from multiple projects into one. For example, if you want to share tasks between projects via git modules, generate tasks definitions dynamically, organise your code in a monorepo, or simply have a lot of tasks and don't want the pyproject.toml to get too large. This can be achieved by creating a toml or json file including the same structure for tasks as used in pyproject.toml +There are some scenarios where one might wish to define tasks outside of pyproject.toml, or to collect tasks from multiple projects into one. For example, if you want to share tasks between projects via git modules, generate tasks definitions dynamically, organise your code in a monorepo, or simply have a lot of tasks and don't want the pyproject.toml to get too large. This can be achieved by creating a toml, yaml, or json file including the same structure for tasks as used in pyproject.toml + +.. tip:: + + Imported toml, yaml, or json files are not required to namespace config under ``tool.poe``. However if config exists under this structure then it will be used. For example: diff --git a/docs/guides/index.rst b/docs/guides/index.rst index d4e230e46..5511fba28 100644 --- a/docs/guides/index.rst +++ b/docs/guides/index.rst @@ -12,4 +12,5 @@ This section contains guides for using the various features of Poe the Poet. composition_guide include_guide global_tasks + without_poetry library_guide diff --git a/docs/guides/without_poetry.rst b/docs/guides/without_poetry.rst new file mode 100644 index 000000000..84829caab --- /dev/null +++ b/docs/guides/without_poetry.rst @@ -0,0 +1,63 @@ +Usage without poetry +==================== + +Poe the Poet was originally intended as the missing task runner for |poetry_link|. But it works just as well with any other kind of virtualenv, or simply as a general purpose way to define handy tasks for use within a certain directory structure! This behaviour is configurable via the :ref:`tool.poe.executor global option`. + +By default poe will run tasks in the poetry managed virtual environment, if the pyproject.toml contains a :toml:`tool.poetry` section. If it doesn't then poe looks for a virtualenv to use at ``./.venv`` or ``./venv`` relative to the pyproject.toml. + +If no virtualenv is found then poe will run tasks without any special environment management. + + +Usage without pyproject.toml +---------------------------- + +When using Poe the Poet outside of a poetry (or other |pep518_link|) project, you can avoid the potential confusion of creating a ``pyproject.toml`` file and instead name the file ``poe_tasks.toml``. + + +Usage with with json or yaml instead of toml +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +As an alternative to toml, poethepoet configuration can also be provided via yaml or json files. When searching for a file to load config from within a certain directory poe will try the following file names in order: + +- pyproject.toml +- poe_tasks.toml +- poe_tasks.yaml +- poe_tasks.json + +If pyproject.toml exists but does not contain the key prefix ``tool.poe`` then the search continues with ``poe_tasks.toml``. If of the listed ``poe_tasks.*`` files exist then the search terminates, even if the file is empty. + +When config is loaded from files other than pyproject.toml the ``tool.poe`` namespace for poe config is optional. So for example the following two examples of poe_tasks.yaml files are equivalent and both valid: + +.. code-block:: yaml + :caption: poe_tasks.yaml + + env: + VAR0: FOO + + tasks: + show-vars: + cmd: "echo $VAR0 $VAR1 $VAR2" + env: + VAR1: BAR + args: + - name: VAR2 + options: ["--var"] + default: BAZ + +.. code-block:: yaml + :caption: poe_tasks.yaml + + tool: + poe: + env: + VAR0: FOO + + tasks: + show-vars: + cmd: "echo $VAR0 $VAR1 $VAR2" + env: + VAR1: BAR + args: + - name: VAR2 + options: ["--var"] + default: BAZ diff --git a/docs/index.rst b/docs/index.rst index 7fb2575b4..387b5cd8b 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -26,7 +26,7 @@ Poe the Poet Documentation :target: https://pypi.org/project/poethepoet/ :alt: PyPI -.. image:: https://img.shields.io/pypi/dw/poethepoet +.. image:: https://img.shields.io/pypi/dm/poethepoet :target: https://pypistats.org/packages/poethepoet :alt: PyPI - Downloads @@ -44,7 +44,7 @@ It provides a simple way to define project tasks within your pyproject.toml, and Top features ============ -|V| Straight forward declaration of project tasks in your pyproject.toml +|V| Straight forward declaration of project tasks in your pyproject.toml (or :doc:`poe_tasks.toml<./guides/without_poetry>`) |V| Tasks are run in poetry's virtualenv (or another env you specify) @@ -68,6 +68,8 @@ Top features |V| Can be :doc:`used as a library<./guides/library_guide>` to embed in other tools +|V| Also works fine :doc:`without poetry<./guides/without_poetry>` + Quick start =========== @@ -105,16 +107,6 @@ Quick start If you're using |poetry_link|, then poe will automatically use the poetry managed virtualenv to find executables and python libraries, without needing to use ``poetry run`` or ``poetry shell``. -Usage without poetry -==================== - -Poe the Poet was originally intended as the missing task runner for |poetry_link|. But it works just as well with any other kind of virtualenv, or simply as a general purpose way to define handy tasks for use within a certain directory structure! This behaviour is configurable via the :ref:`tool.poe.executor global option`. - -By default poe will run tasks in the poetry managed virtual environment, if the pyproject.toml contains a :toml:`tool.poetry` section. If it doesn't then poe looks for a virtualenv to use at ``./.venv`` or ``./venv`` relative to the pyproject.toml. - -If no virtualenv is found then poe will run tasks without any special environment management. - - Run poe from anywhere ===================== @@ -122,12 +114,4 @@ By default poe will detect when you're inside a project with a pyproject.toml in In all cases the path to project root (where the pyproject.toml resides) will be available as :sh:`$POE_ROOT` within the command line and process. The variable :sh:`$POE_PWD` contains the original working directory from which poe was run. -.. |poetry_link| raw:: html - - poetry - -.. |pipx_link| raw:: html - - pipx - Using this feature you can also define :doc:`global tasks<./guides/global_tasks>` that are not associated with any particular project. diff --git a/poethepoet/app.py b/poethepoet/app.py index 8a0d44c9a..f0a2dfa91 100644 --- a/poethepoet/app.py +++ b/poethepoet/app.py @@ -43,8 +43,9 @@ class PoeThePoet: instead of having to execute poetry in a subprocess to determine this. :type poetry_env_path: str, optional :param config_name: - The name of the file to load tasks and configuration from, defaults to - "pyproject.toml" + The name of the file to load tasks and configuration from. If not set then poe + will search for config by the following file names: pyproject.toml + poe_tasks.toml poe_tasks.yaml poe_tasks.json :type config_name: str, optional :param program_name: The name of the program that is being run. This is used primarily when @@ -64,7 +65,7 @@ def __init__( config: Optional[Union[Mapping[str, Any], "PoeConfig"]] = None, output: IO = sys.stdout, poetry_env_path: Optional[str] = None, - config_name: str = "pyproject.toml", + config_name: Optional[str] = None, program_name: str = "poe", ): from .config import PoeConfig diff --git a/poethepoet/config.py b/poethepoet/config.py deleted file mode 100644 index 6a1562f58..000000000 --- a/poethepoet/config.py +++ /dev/null @@ -1,534 +0,0 @@ -import json -from os import environ -from pathlib import Path -from types import MappingProxyType - -try: - import tomllib as tomli -except ImportError: - import tomli # type: ignore[no-redef] - -from typing import ( - Any, - Dict, - Iterator, - List, - Mapping, - Optional, - Sequence, - Tuple, - Type, - Union, -) - -from .exceptions import ConfigValidationError, PoeException -from .options import NoValue, PoeOptions - -POE_DEBUG = environ.get("POE_DEBUG", "0") == "1" - - -class ConfigPartition: - options: PoeOptions - full_config: Mapping[str, Any] - poe_options: Mapping[str, Any] - path: Path - project_dir: Path - _cwd: Optional[Path] - - ConfigOptions: Type[PoeOptions] - is_primary: bool = False - - def __init__( - self, - full_config: Mapping[str, Any], - path: Path, - project_dir: Optional[Path] = None, - cwd: Optional[Path] = None, - strict: bool = True, - ): - self.poe_options: Mapping[str, Any] = ( - full_config["tool"].get("poe", {}) - if "tool" in full_config - else full_config.get("tool.poe", {}) - ) - self.options = next( - self.ConfigOptions.parse( - self.poe_options, - strict=strict, - # Allow and standard config keys, even if not declared - # This avoids misguided validation errors on included config - extra_keys=tuple(ProjectConfig.ConfigOptions.get_fields()), - ) - ) - self.full_config = full_config - self.path = path - self._cwd = cwd - self.project_dir = project_dir or self.path.parent - - @property - def cwd(self): - return self._cwd or self.project_dir - - @property - def config_dir(self): - return self._cwd or self.path.parent - - def get(self, key: str, default: Any = NoValue): - return self.options.get(key, default) - - -EmptyDict: Mapping = MappingProxyType({}) - - -class ProjectConfig(ConfigPartition): - is_primary = True - - class ConfigOptions(PoeOptions): - """ - Options supported directly under tool.poe in the main config i.e. pyproject.toml - """ - - default_task_type: str = "cmd" - default_array_task_type: str = "sequence" - default_array_item_task_type: str = "ref" - env: Mapping[str, str] = EmptyDict - envfile: Union[str, Sequence[str]] = tuple() - executor: Mapping[str, str] = MappingProxyType({"type": "auto"}) - include: Sequence[str] = tuple() - poetry_command: str = "poe" - poetry_hooks: Mapping[str, str] = EmptyDict - shell_interpreter: Union[str, Sequence[str]] = "posix" - verbosity: int = 0 - tasks: Mapping[str, Any] = EmptyDict - - @classmethod - def normalize( - cls, - config: Any, - strict: bool = True, - ): - if isinstance(config, (list, tuple)): - raise ConfigValidationError("Expected ") - - # Normalize include option: - # > Union[str, Sequence[str], Mapping[str, str]] => List[dict] - if "include" in config: - includes: Any = [] - include_option = config.get("include", None) - - if isinstance(include_option, (dict, str)): - include_option = [include_option] - - if isinstance(include_option, list): - valid_keys = {"path", "cwd"} - for include in include_option: - if isinstance(include, str): - includes.append({"path": include}) - elif ( - isinstance(include, dict) - and include.get("path") - and set(include.keys()) <= valid_keys - ): - includes.append(include) - else: - raise ConfigValidationError( - f"Invalid item for the include option {include!r}", - global_option="include", - ) - else: - # Something is wrong, let option validation handle it - includes = include_option - - config = {**config, "include": includes} - - yield config - - def validate(self): - """ - Validation rules that don't require any extra context go here. - """ - super().validate() - - from .executor import PoeExecutor - from .task.base import PoeTask - - # Validate default_task_type value - if not PoeTask.is_task_type(self.default_task_type, content_type=str): - raise ConfigValidationError( - "Invalid value for option 'default_task_type': " - f"{self.default_task_type!r}\n" - f"Expected one of {PoeTask.get_task_types(str)!r}" - ) - - # Validate default_array_task_type value - if not PoeTask.is_task_type( - self.default_array_task_type, content_type=list - ): - raise ConfigValidationError( - "Invalid value for option 'default_array_task_type': " - f"{self.default_array_task_type!r}\n" - f"Expected one of {PoeTask.get_task_types(list)!r}" - ) - - # Validate default_array_item_task_type value - if not PoeTask.is_task_type( - self.default_array_item_task_type, content_type=str - ): - raise ConfigValidationError( - "Invalid value for option 'default_array_item_task_type': " - f"{self.default_array_item_task_type!r}\n" - f"Expected one of {PoeTask.get_task_types(str)!r}" - ) - - # Validate shell_interpreter type - if self.shell_interpreter: - shell_interpreter = ( - (self.shell_interpreter,) - if isinstance(self.shell_interpreter, str) - else self.shell_interpreter - ) - for interpreter in shell_interpreter: - if interpreter not in PoeConfig.KNOWN_SHELL_INTERPRETERS: - raise ConfigValidationError( - f"Unsupported value {interpreter!r} for option " - "'shell_interpreter'\n" - f"Expected one of {PoeConfig.KNOWN_SHELL_INTERPRETERS!r}" - ) - - # Validate default verbosity. - if self.verbosity < -1 or self.verbosity > 2: - raise ConfigValidationError( - f"Invalid value for option 'verbosity': {self.verbosity!r},\n" - "Expected value be between -1 and 2." - ) - - self.validate_env(self.env) - - # Validate executor config - PoeExecutor.validate_config(self.executor) - - @classmethod - def validate_env(cls, env: Mapping[str, str]): - # Validate env value - for key, value in env.items(): - if isinstance(value, dict): - if tuple(value.keys()) != ("default",) or not isinstance( - value["default"], str - ): - raise ConfigValidationError( - f"Invalid declaration at {key!r} in option 'env': {value!r}" - ) - elif not isinstance(value, str): - raise ConfigValidationError( - f"Value of {key!r} in option 'env' should be a string, " - f"but found {type(value).__name__!r}" - ) - - -class IncludedConfig(ConfigPartition): - class ConfigOptions(PoeOptions): - """ - Options supported directly under tool.poe in included config files - """ - - env: Mapping[str, str] = EmptyDict - envfile: Union[str, Sequence[str]] = tuple() - tasks: Mapping[str, Any] = EmptyDict - - def validate(self): - """ - Validation rules that don't require any extra context go here. - """ - super().validate() - - # Apply same validation to env option as for the main config - ProjectConfig.ConfigOptions.validate_env(self.env) - - -class PoeConfig: - _project_config: ProjectConfig - _included_config: List[IncludedConfig] - - KNOWN_SHELL_INTERPRETERS = ( - "posix", - "sh", - "bash", - "zsh", - "fish", - "pwsh", # powershell >= 6 - "powershell", # any version of powershell - "python", - ) - - """ - The filename to look for when loading config - """ - _config_name: str = "pyproject.toml" - """ - The parent directory of the project config file - """ - _project_dir: Path - """ - This can be overridden, for example to align with poetry - """ - _baseline_verbosity: int = 0 - - def __init__( - self, - cwd: Optional[Union[Path, str]] = None, - table: Optional[Mapping[str, Any]] = None, - config_name: str = "pyproject.toml", - ): - self._config_name = config_name - self._project_dir = self._resolve_project_dir( - Path().resolve() if cwd is None else Path(cwd) - ) - self._project_config = ProjectConfig( - {"tool.poe": table or {}}, path=self._project_dir, strict=False - ) - self._included_config = [] - - def lookup_task( - self, name: str - ) -> Union[Tuple[Mapping[str, Any], ConfigPartition], Tuple[None, None]]: - task = self._project_config.get("tasks", {}).get(name, None) - if task is not None: - return task, self._project_config - - for include in reversed(self._included_config): - task = include.get("tasks", {}).get(name, None) - if task is not None: - return task, include - - return None, None - - def partitions(self, included_first=True) -> Iterator[ConfigPartition]: - if not included_first: - yield self._project_config - yield from self._included_config - if included_first: - yield self._project_config - - @property - def executor(self) -> Mapping[str, Any]: - return self._project_config.options.executor - - @property - def task_names(self) -> Iterator[str]: - result = list(self._project_config.get("tasks", {}).keys()) - for config_part in self._included_config: - for task_name in config_part.get("tasks", {}).keys(): - # Don't use a set to dedup because we want to preserve task order - if task_name not in result: - result.append(task_name) - yield from result - - @property - def tasks(self) -> Dict[str, Any]: - result = dict(self._project_config.get("tasks", {})) - for config in self._included_config: - for task_name, task_def in config.get("tasks", {}).items(): - if task_name in result: - continue - result[task_name] = task_def - return result - - @property - def default_task_type(self) -> str: - return self._project_config.options.default_task_type - - @property - def default_array_task_type(self) -> str: - return self._project_config.options.default_array_task_type - - @property - def default_array_item_task_type(self) -> str: - return self._project_config.options.default_array_item_task_type - - @property - def shell_interpreter(self) -> Tuple[str, ...]: - raw_value = self._project_config.options.shell_interpreter - if isinstance(raw_value, list): - return tuple(raw_value) - return (raw_value,) - - @property - def verbosity(self) -> int: - return self._project_config.get("verbosity", self._baseline_verbosity) - - @property - def is_poetry_project(self) -> bool: - return "poetry" in self._project_config.full_config.get("tool", {}) - - @property - def project_dir(self) -> Path: - return self._project_dir - - def load(self, target_path: Optional[Union[Path, str]] = None, strict: bool = True): - """ - target_path is the path to a file or directory for loading config - If strict is false then some errors in the config structure are tolerated - """ - - config_path = self.find_config_file( - target_path=Path(target_path) if target_path else None, - search_parent=not target_path, - ) - self._project_dir = config_path.parent - - try: - self._project_config = ProjectConfig( - self._read_config_file(config_path), - path=config_path, - project_dir=self._project_dir, - strict=strict, - ) - except KeyError: - raise PoeException( - f"No poe configuration found in file at {self._config_name}" - ) - except ConfigValidationError: - # Try again to load Config with minimal validation so we can still display - # the task list alongside the error - self._project_config = ProjectConfig( - self._read_config_file(config_path), - path=config_path, - project_dir=self._project_dir, - strict=False, - ) - raise - - self._load_includes(strict=strict) - - def find_config_file( - self, target_path: Optional[Path] = None, search_parent: bool = True - ) -> Path: - """ - If search_parent is False then check if the target_path points to a config file - or a directory containing a config file. - - If search_parent is True then also search for the config file in parent - directories in ascending order. - - If no target_path is provided then start with self._project_dir - - If the given target_path is a file, then it may be named as any toml or json - file, otherwise the config file name must match `self._config_name`. - - If no config file can be found then raise a PoeException - """ - if target_path is None: - target_path = self._project_dir - else: - target_path = target_path.resolve() - - if not search_parent: - if not ( - target_path.name.endswith(".toml") or target_path.name.endswith(".json") - ): - target_path = target_path.joinpath(self._config_name) - if not target_path.exists(): - raise PoeException( - f"Poe could not find a {self._config_name!r} file at the given " - f"location: {str(target_path)!r}" - ) - return target_path - - return self._resolve_project_dir(target_path, raise_on_fail=True) - - def _resolve_project_dir(self, target_dir: Path, raise_on_fail: bool = False): - """ - Look for the self._config_name in the current working directory, - followed by all parent directories in ascending order. - Return the path of the parent directory of the first config file found. - """ - maybe_result = target_dir.joinpath(self._config_name) - while not maybe_result.exists(): - if len(maybe_result.parents) == 1: - if raise_on_fail: - raise PoeException( - f"Poe could not find a {self._config_name!r} file in " - f"{target_dir} or any parent directory." - ) - else: - return target_dir - maybe_result = maybe_result.parents[1].joinpath(self._config_name).resolve() - return maybe_result - - def _load_includes(self: "PoeConfig", strict: bool = True): - # Attempt to load each of the included configs - for include in self._project_config.options.include: - include_path = self._resolve_include_path(include["path"]) - - if not include_path.exists(): - # TODO: print warning in verbose mode, requires access to ui somehow - # Maybe there should be something like a WarningService? - - if POE_DEBUG: - print(f" ! Could not include file from invalid path {include_path}") - continue - - try: - self._included_config.append( - IncludedConfig( - self._read_config_file(include_path), - path=include_path, - project_dir=self._project_dir, - cwd=( - self.project_dir.joinpath(include["cwd"]).resolve() - if include.get("cwd") - else None - ), - strict=strict, - ) - ) - if POE_DEBUG: - print(f" Included config from {include_path}") - except (PoeException, KeyError) as error: - raise ConfigValidationError( - f"Invalid content in included file from {include_path}", - filename=str(include_path), - ) from error - - def _resolve_include_path(self, include_path: str): - from .env.template import apply_envvars_to_template - - available_vars = {"POE_ROOT": str(self._project_dir)} - - if "${POE_GIT_DIR}" in include_path: - from .helpers.git import GitRepo - - git_repo = GitRepo(self._project_dir) - available_vars["POE_GIT_DIR"] = str(git_repo.path or "") - - if "${POE_GIT_ROOT}" in include_path: - from .helpers.git import GitRepo - - git_repo = GitRepo(self._project_dir) - available_vars["POE_GIT_ROOT"] = str(git_repo.main_path or "") - - include_path = apply_envvars_to_template( - include_path, available_vars, require_braces=True - ) - - return self._project_dir.joinpath(include_path).resolve() - - @staticmethod - def _read_config_file(path: Path) -> Mapping[str, Any]: - try: - with path.open("rb") as file: - if path.suffix.endswith(".json"): - return json.load(file) - else: - return tomli.load(file) - - except tomli.TOMLDecodeError as error: - raise PoeException(f"Couldn't parse toml file at {path}", error) from error - - except json.decoder.JSONDecodeError as error: - raise PoeException( - f"Couldn't parse json file from {path}", error - ) from error - - except Exception as error: - raise PoeException(f"Couldn't open file at {path}") from error diff --git a/poethepoet/config/__init__.py b/poethepoet/config/__init__.py new file mode 100644 index 000000000..6310028e1 --- /dev/null +++ b/poethepoet/config/__init__.py @@ -0,0 +1,4 @@ +from .config import PoeConfig +from .partition import KNOWN_SHELL_INTERPRETERS, ConfigPartition + +__all__ = ["PoeConfig", "ConfigPartition", "KNOWN_SHELL_INTERPRETERS"] diff --git a/poethepoet/config/config.py b/poethepoet/config/config.py new file mode 100644 index 000000000..dda601fec --- /dev/null +++ b/poethepoet/config/config.py @@ -0,0 +1,247 @@ +from os import environ +from pathlib import Path +from typing import ( + Any, + Dict, + Iterator, + List, + Mapping, + Optional, + Sequence, + Tuple, + Union, +) + +from ..exceptions import ConfigValidationError, PoeException +from .file import PoeConfigFile +from .partition import ConfigPartition, IncludedConfig, ProjectConfig + +POE_DEBUG = environ.get("POE_DEBUG", "0") == "1" + + +class PoeConfig: + _project_config: ProjectConfig + _included_config: List[IncludedConfig] + + """ + The filenames to look for when loading config + """ + _config_filenames: Tuple[str, ...] = ( + "pyproject.toml", + "poe_tasks.toml", + "poe_tasks.yaml", + "poe_tasks.json", + ) + """ + The parent directory of the project config file + """ + _project_dir: Path + """ + This can be overridden, for example to align with poetry + """ + _baseline_verbosity: int = 0 + + def __init__( + self, + cwd: Optional[Union[Path, str]] = None, + table: Optional[Mapping[str, Any]] = None, + config_name: Optional[Union[str, Sequence[str]]] = None, + ): + if config_name is not None: + if isinstance(config_name, str): + self._config_filenames = (config_name,) + else: + self._config_filenames = tuple(config_name) + + self._project_dir = Path().resolve() if cwd is None else Path(cwd) + self._project_config = ProjectConfig( + {"tool.poe": table or {}}, path=self._project_dir, strict=False + ) + self._included_config = [] + + def lookup_task( + self, name: str + ) -> Union[Tuple[Mapping[str, Any], ConfigPartition], Tuple[None, None]]: + task = self._project_config.get("tasks", {}).get(name, None) + if task is not None: + return task, self._project_config + + for include in reversed(self._included_config): + task = include.get("tasks", {}).get(name, None) + if task is not None: + return task, include + + return None, None + + def partitions(self, included_first=True) -> Iterator[ConfigPartition]: + if not included_first: + yield self._project_config + yield from self._included_config + if included_first: + yield self._project_config + + @property + def executor(self) -> Mapping[str, Any]: + return self._project_config.options.executor + + @property + def task_names(self) -> Iterator[str]: + result = list(self._project_config.get("tasks", {}).keys()) + for config_part in self._included_config: + for task_name in config_part.get("tasks", {}).keys(): + # Don't use a set to dedup because we want to preserve task order + if task_name not in result: + result.append(task_name) + yield from result + + @property + def tasks(self) -> Dict[str, Any]: + result = dict(self._project_config.get("tasks", {})) + for config in self._included_config: + for task_name, task_def in config.get("tasks", {}).items(): + if task_name in result: + continue + result[task_name] = task_def + return result + + @property + def default_task_type(self) -> str: + return self._project_config.options.default_task_type + + @property + def default_array_task_type(self) -> str: + return self._project_config.options.default_array_task_type + + @property + def default_array_item_task_type(self) -> str: + return self._project_config.options.default_array_item_task_type + + @property + def shell_interpreter(self) -> Tuple[str, ...]: + raw_value = self._project_config.options.shell_interpreter + if isinstance(raw_value, list): + return tuple(raw_value) + return (raw_value,) + + @property + def verbosity(self) -> int: + return self._project_config.get("verbosity", self._baseline_verbosity) + + @property + def is_poetry_project(self) -> bool: + return "poetry" in self._project_config.full_config.get("tool", {}) + + @property + def project_dir(self) -> Path: + return self._project_dir + + def load(self, target_path: Optional[Union[Path, str]] = None, strict: bool = True): + """ + target_path is the path to a file or directory for loading config + If strict is false then some errors in the config structure are tolerated + """ + + for config_file in PoeConfigFile.find_config_files( + target_path=Path(target_path or self._project_dir), + filenames=self._config_filenames, + search_parent=not target_path, + ): + config_file.load() + + if config_file.error: + raise config_file.error + + elif config_file.is_valid: + self._project_dir = config_file.path.parent + + config_content = config_file.load() + assert config_content + + try: + self._project_config = ProjectConfig( + config_content, + path=config_file.path, + project_dir=self._project_dir, + strict=strict, + ) + except ConfigValidationError: + # Try again to load Config with minimal validation so we can still + # display the task list alongside the error + self._project_config = ProjectConfig( + config_content, + path=config_file.path, + project_dir=self._project_dir, + strict=False, + ) + raise + + break + + else: + raise PoeException( + f"No poe configuration found from location {target_path}" + ) + + self._load_includes(strict=strict) + + def _load_includes(self: "PoeConfig", strict: bool = True): + # Attempt to load each of the included configs + for include in self._project_config.options.include: + include_path = self._resolve_include_path(include["path"]) + + if not include_path.exists(): + # TODO: print warning in verbose mode, requires access to ui somehow + # Maybe there should be something like a WarningService? + + if POE_DEBUG: + print(f" ! Could not include file from invalid path {include_path}") + continue + + try: + config_file = PoeConfigFile(include_path) + config_content = config_file.load() + assert config_content + + self._included_config.append( + IncludedConfig( + config_content, + path=config_file.path, + project_dir=self._project_dir, + cwd=( + self.project_dir.joinpath(include["cwd"]).resolve() + if include.get("cwd") + else None + ), + strict=strict, + ) + ) + if POE_DEBUG: + print(f" Included config from {include_path}") + except (PoeException, KeyError) as error: + raise ConfigValidationError( + f"Invalid content in included file from {include_path}", + filename=str(include_path), + ) from error + + def _resolve_include_path(self, include_path: str): + from ..env.template import apply_envvars_to_template + + available_vars = {"POE_ROOT": str(self._project_dir)} + + if "${POE_GIT_DIR}" in include_path: + from ..helpers.git import GitRepo + + git_repo = GitRepo(self._project_dir) + available_vars["POE_GIT_DIR"] = str(git_repo.path or "") + + if "${POE_GIT_ROOT}" in include_path: + from ..helpers.git import GitRepo + + git_repo = GitRepo(self._project_dir) + available_vars["POE_GIT_ROOT"] = str(git_repo.main_path or "") + + include_path = apply_envvars_to_template( + include_path, available_vars, require_braces=True + ) + + return self._project_dir.joinpath(include_path).resolve() diff --git a/poethepoet/config/file.py b/poethepoet/config/file.py new file mode 100644 index 000000000..0ec05211f --- /dev/null +++ b/poethepoet/config/file.py @@ -0,0 +1,138 @@ +from pathlib import Path +from typing import ( + Any, + Iterator, + Mapping, + Optional, + Sequence, +) + +from ..exceptions import PoeException + + +class PoeConfigFile: + path: Path + _content: Optional[Mapping[str, Any]] = None + _error: Optional[PoeException] = None + _valid: bool = False + + def __init__(self, path: Path): + self.path = path + + @property + def content(self) -> Optional[Mapping[str, Any]]: + return self._content + + @property + def is_valid(self) -> bool: + return self._valid + + @property + def error(self) -> Optional[PoeException]: + return self._error + + @property + def is_pyproject(self) -> bool: + return self.path.name == "pyproject.toml" + + def load(self, force: bool = False) -> Optional[Mapping]: + if force or not self._content: + try: + content = self._read_config_file(self.path) + except PoeException as error: + self._error = error + self._valid = False + return None + + if self.is_pyproject or content.get("tool", {}).get("poe", {}): + self._content = content + self._valid = bool(content.get("tool", {}).get("poe", {})) + else: + if tool_poe := content.get("tool.poe"): + self._content = {"tool": {"poe": tool_poe}} + else: + self._content = {"tool": {"poe": content}} + self._valid = True + + return self._content + + @classmethod + def find_config_files( + cls, target_path: Path, filenames: Sequence[str], search_parent: bool = True + ) -> Iterator["PoeConfigFile"]: + """ + Generate a PoeConfigFile for all potential config files starting from the + target_path in order of precedence. + + If search_parent is False then check if the target_path points to a config file + or a directory containing a config file. + + If search_parent is True then also search for the config file in parent + directories in ascending order. + + If the given target_path is a file, then it may be named as any toml, json, or + yaml file, otherwise the config file name must match one of `filenames`. + """ + + def scan_dir(target_dir: Path): + for filename in filenames: + if target_dir.joinpath(filename).exists(): + yield cls(target_dir.joinpath(filename)) + + target_path = target_path.resolve() + + if target_path.is_dir(): + yield from scan_dir(target_path) + + if search_parent: + parent_path = target_path + while len(parent_path.parents) > 1: + parent_path = parent_path.parent + yield from scan_dir(parent_path) + + elif target_path.exists() and target_path.name.endswith( + (".toml", ".json", ".yaml") + ): + yield cls(target_path) + + @staticmethod + def _read_config_file(path: Path) -> Mapping[str, Any]: + try: + if path.suffix.endswith(".json"): + import json + + try: + with path.open("rb") as file: + return json.load(file) + except json.decoder.JSONDecodeError as error: + raise PoeException( + f"Couldn't parse json file from {path}", error + ) from error + + elif path.suffix.endswith(".yaml"): + import yaml + + try: + with path.open("rb") as file: + return yaml.safe_load(file) + except yaml.parser.ParserError as error: + raise PoeException( + f"Couldn't parse yaml file from {path}", error + ) from error + + else: + try: + import tomllib as tomli + except ImportError: + import tomli # type: ignore[no-redef] + + try: + with path.open("rb") as file: + return tomli.load(file) + except tomli.TOMLDecodeError as error: + raise PoeException( + f"Couldn't parse toml file at {path}", error + ) from error + + except Exception as error: + raise PoeException(f"Couldn't open file at {path}") from error diff --git a/poethepoet/config/partition.py b/poethepoet/config/partition.py new file mode 100644 index 000000000..2ce2994fc --- /dev/null +++ b/poethepoet/config/partition.py @@ -0,0 +1,242 @@ +from pathlib import Path +from types import MappingProxyType +from typing import ( + Any, + Mapping, + Optional, + Sequence, + Type, + Union, +) + +from ..exceptions import ConfigValidationError +from ..options import NoValue, PoeOptions + +KNOWN_SHELL_INTERPRETERS = ( + "posix", + "sh", + "bash", + "zsh", + "fish", + "pwsh", # powershell >= 6 + "powershell", # any version of powershell + "python", +) + + +class ConfigPartition: + options: PoeOptions + full_config: Mapping[str, Any] + poe_options: Mapping[str, Any] + path: Path + project_dir: Path + _cwd: Optional[Path] + + ConfigOptions: Type[PoeOptions] + is_primary: bool = False + + def __init__( + self, + full_config: Mapping[str, Any], + path: Path, + project_dir: Optional[Path] = None, + cwd: Optional[Path] = None, + strict: bool = True, + ): + self.poe_options: Mapping[str, Any] = ( + full_config["tool"].get("poe", {}) + if "tool" in full_config + else full_config.get("tool.poe", {}) + ) + self.options = next( + self.ConfigOptions.parse( + self.poe_options, + strict=strict, + # Allow and standard config keys, even if not declared + # This avoids misguided validation errors on included config + extra_keys=tuple(ProjectConfig.ConfigOptions.get_fields()), + ) + ) + self.full_config = full_config + self.path = path + self._cwd = cwd + self.project_dir = project_dir or self.path.parent + + @property + def cwd(self): + return self._cwd or self.project_dir + + @property + def config_dir(self): + return self._cwd or self.path.parent + + def get(self, key: str, default: Any = NoValue): + return self.options.get(key, default) + + +EmptyDict: Mapping = MappingProxyType({}) + + +class ProjectConfig(ConfigPartition): + is_primary = True + + class ConfigOptions(PoeOptions): + """ + Options supported directly under tool.poe in the main config i.e. pyproject.toml + """ + + default_task_type: str = "cmd" + default_array_task_type: str = "sequence" + default_array_item_task_type: str = "ref" + env: Mapping[str, str] = EmptyDict + envfile: Union[str, Sequence[str]] = tuple() + executor: Mapping[str, str] = MappingProxyType({"type": "auto"}) + include: Sequence[str] = tuple() + poetry_command: str = "poe" + poetry_hooks: Mapping[str, str] = EmptyDict + shell_interpreter: Union[str, Sequence[str]] = "posix" + verbosity: int = 0 + tasks: Mapping[str, Any] = EmptyDict + + @classmethod + def normalize( + cls, + config: Any, + strict: bool = True, + ): + if isinstance(config, (list, tuple)): + raise ConfigValidationError("Expected ") + + # Normalize include option: + # > Union[str, Sequence[str], Mapping[str, str]] => List[dict] + if "include" in config: + includes: Any = [] + include_option = config.get("include", None) + + if isinstance(include_option, (dict, str)): + include_option = [include_option] + + if isinstance(include_option, list): + valid_keys = {"path", "cwd"} + for include in include_option: + if isinstance(include, str): + includes.append({"path": include}) + elif ( + isinstance(include, dict) + and include.get("path") + and set(include.keys()) <= valid_keys + ): + includes.append(include) + else: + raise ConfigValidationError( + f"Invalid item for the include option {include!r}", + global_option="include", + ) + else: + # Something is wrong, let option validation handle it + includes = include_option + + config = {**config, "include": includes} + + yield config + + def validate(self): + """ + Validation rules that don't require any extra context go here. + """ + super().validate() + + from ..executor import PoeExecutor + from ..task.base import PoeTask + + # Validate default_task_type value + if not PoeTask.is_task_type(self.default_task_type, content_type=str): + raise ConfigValidationError( + "Invalid value for option 'default_task_type': " + f"{self.default_task_type!r}\n" + f"Expected one of {PoeTask.get_task_types(str)!r}" + ) + + # Validate default_array_task_type value + if not PoeTask.is_task_type( + self.default_array_task_type, content_type=list + ): + raise ConfigValidationError( + "Invalid value for option 'default_array_task_type': " + f"{self.default_array_task_type!r}\n" + f"Expected one of {PoeTask.get_task_types(list)!r}" + ) + + # Validate default_array_item_task_type value + if not PoeTask.is_task_type( + self.default_array_item_task_type, content_type=str + ): + raise ConfigValidationError( + "Invalid value for option 'default_array_item_task_type': " + f"{self.default_array_item_task_type!r}\n" + f"Expected one of {PoeTask.get_task_types(str)!r}" + ) + + # Validate shell_interpreter type + if self.shell_interpreter: + shell_interpreter = ( + (self.shell_interpreter,) + if isinstance(self.shell_interpreter, str) + else self.shell_interpreter + ) + for interpreter in shell_interpreter: + if interpreter not in KNOWN_SHELL_INTERPRETERS: + raise ConfigValidationError( + f"Unsupported value {interpreter!r} for option " + "'shell_interpreter'\n" + f"Expected one of {KNOWN_SHELL_INTERPRETERS!r}" + ) + + # Validate default verbosity. + if self.verbosity < -1 or self.verbosity > 2: + raise ConfigValidationError( + f"Invalid value for option 'verbosity': {self.verbosity!r},\n" + "Expected value be between -1 and 2." + ) + + self.validate_env(self.env) + + # Validate executor config + PoeExecutor.validate_config(self.executor) + + @classmethod + def validate_env(cls, env: Mapping[str, str]): + # Validate env value + for key, value in env.items(): + if isinstance(value, dict): + if tuple(value.keys()) != ("default",) or not isinstance( + value["default"], str + ): + raise ConfigValidationError( + f"Invalid declaration at {key!r} in option 'env': {value!r}" + ) + elif not isinstance(value, str): + raise ConfigValidationError( + f"Value of {key!r} in option 'env' should be a string, " + f"but found {type(value).__name__!r}" + ) + + +class IncludedConfig(ConfigPartition): + class ConfigOptions(PoeOptions): + """ + Options supported directly under tool.poe in included config files + """ + + env: Mapping[str, str] = EmptyDict + envfile: Union[str, Sequence[str]] = tuple() + tasks: Mapping[str, Any] = EmptyDict + + def validate(self): + """ + Validation rules that don't require any extra context go here. + """ + super().validate() + + # Apply same validation to env option as for the main config + ProjectConfig.ConfigOptions.validate_env(self.env) diff --git a/poethepoet/task/shell.py b/poethepoet/task/shell.py index d91c6c298..2e2752541 100644 --- a/poethepoet/task/shell.py +++ b/poethepoet/task/shell.py @@ -31,17 +31,15 @@ class TaskOptions(PoeTask.TaskOptions): def validate(self): super().validate() - from ..config import PoeConfig - - valid_interpreters = PoeConfig.KNOWN_SHELL_INTERPRETERS + from ..config import KNOWN_SHELL_INTERPRETERS as VALID_INTERPRETERS if ( isinstance(self.interpreter, str) - and self.interpreter not in valid_interpreters + and self.interpreter not in VALID_INTERPRETERS ): raise ConfigValidationError( "Invalid value for option 'interpreter',\n" - f"Expected one of {valid_interpreters}" + f"Expected one of {VALID_INTERPRETERS}" ) if isinstance(self.interpreter, list): @@ -51,10 +49,10 @@ def validate(self): "Expected at least one item in list." ) for item in self.interpreter: - if item not in valid_interpreters: + if item not in VALID_INTERPRETERS: raise ConfigValidationError( f"Invalid item {item!r} in option 'interpreter',\n" - f"Expected one of {valid_interpreters!r}" + f"Expected one of {VALID_INTERPRETERS!r}" ) class TaskSpec(PoeTask.TaskSpec): diff --git a/poetry.lock b/poetry.lock index 027dfbf46..3b606e9c8 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.0 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. [[package]] name = "alabaster" @@ -1564,6 +1564,68 @@ files = [ {file = "pyxdg-0.28.tar.gz", hash = "sha256:3267bb3074e934df202af2ee0868575484108581e6f3cb006af1da35395e88b4"}, ] +[[package]] +name = "pyyaml" +version = "6.0.2" +description = "YAML parser and emitter for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, + {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed"}, + {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180"}, + {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68"}, + {file = "PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99"}, + {file = "PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e"}, + {file = "PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774"}, + {file = "PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85"}, + {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4"}, + {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e"}, + {file = "PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5"}, + {file = "PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44"}, + {file = "PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab"}, + {file = "PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476"}, + {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48"}, + {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b"}, + {file = "PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4"}, + {file = "PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8"}, + {file = "PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba"}, + {file = "PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5"}, + {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc"}, + {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652"}, + {file = "PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183"}, + {file = "PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563"}, + {file = "PyYAML-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083"}, + {file = "PyYAML-6.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706"}, + {file = "PyYAML-6.0.2-cp38-cp38-win32.whl", hash = "sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a"}, + {file = "PyYAML-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff"}, + {file = "PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d"}, + {file = "PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19"}, + {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e"}, + {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725"}, + {file = "PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631"}, + {file = "PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8"}, + {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"}, +] + [[package]] name = "requests" version = "2.32.3" @@ -2001,6 +2063,17 @@ rich = ">=10.11.0" shellingham = ">=1.3.0" typing-extensions = ">=3.7.4.3" +[[package]] +name = "types-pyyaml" +version = "6.0.12.20240808" +description = "Typing stubs for PyYAML" +optional = false +python-versions = ">=3.8" +files = [ + {file = "types-PyYAML-6.0.12.20240808.tar.gz", hash = "sha256:b8f76ddbd7f65440a8bda5526a9607e4c7a322dc2f8e1a8c405644f9a6f4b9af"}, + {file = "types_PyYAML-6.0.12.20240808-py3-none-any.whl", hash = "sha256:deda34c5c655265fc517b546c902aa6eed2ef8d3e921e4765fe606fe2afe8d35"}, +] + [[package]] name = "typing-extensions" version = "4.12.2" @@ -2092,4 +2165,4 @@ poetry-plugin = ["poetry"] [metadata] lock-version = "2.0" python-versions = ">=3.8" -content-hash = "bc143bfbfbdc35f145adb30e9427178934ce3eb35654aba47cb3276f12e5d11d" +content-hash = "a1c3afde27de43c35dc6f43cc5e678c8afc92a94c7e460f3c23e75a245813463" diff --git a/pyproject.toml b/pyproject.toml index 108af4f86..1dcb74b45 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,7 @@ python = ">=3.8" pastel = "^0.2.1" tomli = ">=1.2.2" poetry = {version = "^1.0", allow-prereleases = true, optional = true} +pyyaml = "^6.0.2" [tool.poetry.group.ci.dependencies] black = "^23.3.0" @@ -30,7 +31,8 @@ virtualenv = "^20.14.1" poe_test_helpers = { path = "./tests/fixtures/packages/poe_test_helpers" } [tool.poetry.group.dev.dependencies] -bpython = "^0.24" +bpython = "^0.24" +types-pyyaml = "^6.0.12.20240808" [tool.poetry.group.docs.dependencies] furo = "^2023.3.27" diff --git a/tests/fixtures/poe_tasks_file_project/poe_tasks.toml b/tests/fixtures/poe_tasks_file_project/poe_tasks.toml new file mode 100644 index 000000000..d57dad5ba --- /dev/null +++ b/tests/fixtures/poe_tasks_file_project/poe_tasks.toml @@ -0,0 +1,2 @@ +[tasks] +main_poetasks_toml = "poe_test_echo main_project_poetasks_toml" diff --git a/tests/fixtures/poe_tasks_file_project/pyproject.toml b/tests/fixtures/poe_tasks_file_project/pyproject.toml new file mode 100644 index 000000000..37d654086 --- /dev/null +++ b/tests/fixtures/poe_tasks_file_project/pyproject.toml @@ -0,0 +1,13 @@ +[tool.poe] +include = [ + "poe_tasks.toml", + "sub1/poe_tasks.toml", + "sub1/poe_tasks.yaml", + "sub1/poe_tasks.json", + "sub2/poe_tasks.yaml", + "sub2/poe_tasks.json", + "sub3/poe_tasks.json", +] + +[tool.poe.tasks] +main_pyproject = "poe_test_echo main_pyproject" diff --git a/tests/fixtures/poe_tasks_file_project/sub1/poe_tasks.json b/tests/fixtures/poe_tasks_file_project/sub1/poe_tasks.json new file mode 100644 index 000000000..86e8a2a2b --- /dev/null +++ b/tests/fixtures/poe_tasks_file_project/sub1/poe_tasks.json @@ -0,0 +1,9 @@ +{ + "tool": { + "poe": { + "tasks": { + "sub1_poetasks_json": "poe_test_echo sub1_poetasks_json" + } + } + } +} \ No newline at end of file diff --git a/tests/fixtures/poe_tasks_file_project/sub1/poe_tasks.toml b/tests/fixtures/poe_tasks_file_project/sub1/poe_tasks.toml new file mode 100644 index 000000000..6832d62a6 --- /dev/null +++ b/tests/fixtures/poe_tasks_file_project/sub1/poe_tasks.toml @@ -0,0 +1,3 @@ + +[tool.poe.tasks.sub1_poetasks_toml] +cmd = "poe_test_echo sub1_poetasks_toml" diff --git a/tests/fixtures/poe_tasks_file_project/sub1/poe_tasks.yaml b/tests/fixtures/poe_tasks_file_project/sub1/poe_tasks.yaml new file mode 100644 index 000000000..a19880b13 --- /dev/null +++ b/tests/fixtures/poe_tasks_file_project/sub1/poe_tasks.yaml @@ -0,0 +1,3 @@ +tool.poe: + tasks: + sub1_poetasks_yaml: "poe_test_echo sub1_poetasks_yaml" diff --git a/tests/fixtures/poe_tasks_file_project/sub1/pyproject.toml b/tests/fixtures/poe_tasks_file_project/sub1/pyproject.toml new file mode 100644 index 000000000..6ed91810f --- /dev/null +++ b/tests/fixtures/poe_tasks_file_project/sub1/pyproject.toml @@ -0,0 +1,7 @@ +# this pyproject.toml doesn't contain any poe config + +[tool.poetry] +name = "sub1" +version = "0.0.0" +description = "A task runner that works well with poetry." +authors = ["Nat Noordanus "] diff --git a/tests/fixtures/poe_tasks_file_project/sub2/poe_tasks.json b/tests/fixtures/poe_tasks_file_project/sub2/poe_tasks.json new file mode 100644 index 000000000..45e0b685f --- /dev/null +++ b/tests/fixtures/poe_tasks_file_project/sub2/poe_tasks.json @@ -0,0 +1,7 @@ +{ + "tool.poe": { + "tasks": { + "sub2_poetasks_json": "poe_test_echo sub2_poetasks_json" + } + } +} \ No newline at end of file diff --git a/tests/fixtures/poe_tasks_file_project/sub2/poe_tasks.yaml b/tests/fixtures/poe_tasks_file_project/sub2/poe_tasks.yaml new file mode 100644 index 000000000..f94f9bc68 --- /dev/null +++ b/tests/fixtures/poe_tasks_file_project/sub2/poe_tasks.yaml @@ -0,0 +1,2 @@ +tasks: + sub2_poetasks_yaml: "poe_test_echo sub2_poetasks_yaml" diff --git a/tests/fixtures/poe_tasks_file_project/sub2/pyproject.toml b/tests/fixtures/poe_tasks_file_project/sub2/pyproject.toml new file mode 100644 index 000000000..79e8e05df --- /dev/null +++ b/tests/fixtures/poe_tasks_file_project/sub2/pyproject.toml @@ -0,0 +1 @@ +# this pyproject.toml doesn't contain any poe config diff --git a/tests/fixtures/poe_tasks_file_project/sub3/poe_tasks.json b/tests/fixtures/poe_tasks_file_project/sub3/poe_tasks.json new file mode 100644 index 000000000..66351e4e1 --- /dev/null +++ b/tests/fixtures/poe_tasks_file_project/sub3/poe_tasks.json @@ -0,0 +1,6 @@ +{ + "include": "../sub2/poe_tasks.yaml", + "tasks": { + "sub3_poetasks_json": "poe_test_echo sub3_poetasks_json" + } +} \ No newline at end of file diff --git a/tests/test_poe_tasks_file.py b/tests/test_poe_tasks_file.py new file mode 100644 index 000000000..eb7ae328b --- /dev/null +++ b/tests/test_poe_tasks_file.py @@ -0,0 +1,47 @@ +def test_prefer_valid_pyproject(run_poe_subproc, projects): + # and import the poe_tasks from elsewhere + result = run_poe_subproc(project="poe_tasks_file") + assert ( + "Configured tasks:\n" + " main_pyproject \n" + " main_poetasks_toml \n" + " sub1_poetasks_toml \n" + " sub1_poetasks_yaml \n" + " sub1_poetasks_json \n" + " sub2_poetasks_yaml \n" + " sub2_poetasks_json \n" + " sub3_poetasks_json \n\n" + ) in result.capture + assert result.stdout == "" + assert result.stderr == "" + + +def test_prefer_poe_tasks_toml_over_yaml_or_invalid_pyproject( + run_poe_subproc, projects +): + result = run_poe_subproc(cwd=projects["poe_tasks_file"] / "sub1") + assert ("Configured tasks:\n sub1_poetasks_toml \n\n") in result.capture + assert result.stdout == "" + assert result.stderr == "" + + +def test_prefer_poe_tasks_yaml_over_json(run_poe_subproc, projects): + # and import local pyproject.toml + # and use full tool.poe namespace for file contents + result = run_poe_subproc(cwd=projects["poe_tasks_file"] / "sub2") + assert ("Configured tasks:\n sub2_poetasks_yaml \n\n") in result.capture + assert result.stdout == "" + assert result.stderr == "" + + +def test_load_poe_tasks_json(run_poe_subproc, projects): + # and don't namespace file contents + # and import tasks from another directory + result = run_poe_subproc(cwd=projects["poe_tasks_file"] / "sub3") + assert ( + "Configured tasks:\n" + " sub3_poetasks_json \n" + " sub2_poetasks_yaml \n\n" + ) in result.capture + assert result.stdout == "" + assert result.stderr == ""