Skip to content

Commit

Permalink
Add support for POE_GIT_DIR and POE_GIT_ROOT variables in config (#229)
Browse files Browse the repository at this point in the history
Fixes #203

Also:
- Adds GitRepo abstraction in helpers for calling git
- Make EnvVarsManager implement Mapping so it can be passed directly to `apply_envvars_to_template`
- Update poetry.lock
  • Loading branch information
nat-n authored Jun 23, 2024
1 parent 7b0fa64 commit 0b023b8
Show file tree
Hide file tree
Showing 18 changed files with 746 additions and 488 deletions.
10 changes: 10 additions & 0 deletions docs/env_vars.rst
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,16 @@ The following environment variables are used by Poe the Poet internally, and can
- ``POE_CONF_DIR``: the path to the parent directory of the config file that defines the running task or the :ref:`cwd option<Setting a working directory for included tasks>` set when including that config.
- ``POE_ACTIVE``: identifies the active PoeExecutor, so that Poe the Poet can tell when it is running recursively.


Special variables
-----------------

The following variables are not set on the environment by default but can be referenced from task configuration as if they were.

- ``POE_GIT_DIR``: path of the git repo that the project is part of. This allows a project in a subdirectory of a monorepo to reference :ref:`includes<Including files relative to the git repo>` or :ref:`envfiles<Loading environment variables from an env file>` relative to the root of the git repo. Note that referencing this variable causes poe to attempt to call the ``git`` executable which must be available on the path.

- ``POE_GIT_ROOT``: just like ``POE_GIT_DIR`` except that if the project is in a git submodule, then the path will point to the working directory of the main repo above it.

External Environment variables
------------------------------

Expand Down
18 changes: 17 additions & 1 deletion docs/global_options.rst
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,23 @@ You can also specify an env file (with bash-like syntax) to load for all tasks l
[tool.poe]
envfile = ".env"
The envfile global option also accepts a list of env files.
The envfile global option also accepts a list of env files like so.

.. code-block:: toml
[tool.poe]
envfile = ["standard.env", "local.env"]
In this case the referenced files will be loaded in the given order.

Normally envfile paths are resolved relative to the project root (that is the parent directory of the pyproject.toml). However when working with a monorepo it can also be useful to specify the path relative to the root of the git repository, which can be done by referenceing the ``POE_GIT_DIR`` or ``POE_GIT_ROOT`` variables like so:

.. code-block:: toml
[tool.poe]
envfile = "${POE_GIT_DIR}/.env"
See the documentation on :ref:`Special variables<Special variables>` for a full explanation of how these variables work.

Change the executor type
------------------------
Expand Down
13 changes: 13 additions & 0 deletions docs/guides/include_guide.rst
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,16 @@ You can still specify that an envfile referenced within an included file should
[tool.poe]
envfile = "${POE_ROOT}/.env"
Including files relative to the git repo
----------------------------------------

Normally include paths are resolved relative to the project root (that is the parent directory of the pyproject.toml). However when working with a monorepo it can also be useful to specify the file to include relative to the root of the git repository, which can be done by referenceing the ``POE_GIT_DIR`` or ``POE_GIT_ROOT`` variables like so:

.. code-block:: toml
[tool.poe]
include = "${POE_GIT_DIR}/tasks.toml"
See the documentation on :ref:`Special variables<Special variables>` for a full explanation of how these variables work.
8 changes: 8 additions & 0 deletions docs/tasks/options.rst
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,14 @@ above but can also by given a list of such paths like so:
In this case the referenced files will be loaded in the given order.

Normally envfile paths are resolved relative to the project root (that is the parent directory of the pyproject.toml). However when working with a monorepo it can also be useful to specify the path relative to the root of the git repository, which can be done by referenceing the ``POE_GIT_DIR`` or ``POE_GIT_ROOT`` variables like so:

.. code-block:: toml
[tool.poe]
envfile = "${POE_GIT_DIR}/.env"
See the documentation on :ref:`Special variables<Special variables>` for a full explanation of how these variables work.

Running a task with a specific working directory
------------------------------------------------
Expand Down
35 changes: 33 additions & 2 deletions poethepoet/config.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import json
from os import environ
from pathlib import Path
from types import MappingProxyType

Expand All @@ -23,6 +24,8 @@
from .exceptions import ConfigValidationError, PoeException
from .options import NoValue, PoeOptions

POE_DEBUG = environ.get("POE_DEBUG", "0") == "1"


class ConfigPartition:
options: PoeOptions
Expand Down Expand Up @@ -427,7 +430,7 @@ def find_config_file(
if not target_path.exists():
raise PoeException(
f"Poe could not find a {self._config_name!r} file at the given "
f"location: {target_path!r}"
f"location: {str(target_path)!r}"
)
return target_path

Expand Down Expand Up @@ -455,11 +458,14 @@ def _resolve_project_dir(self, target_dir: Path, raise_on_fail: bool = False):
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._project_dir.joinpath(include["path"]).resolve()
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:
Expand All @@ -476,12 +482,37 @@ def _load_includes(self: "PoeConfig", strict: bool = True):
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:
Expand Down
2 changes: 1 addition & 1 deletion poethepoet/env/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ def get(self, envfile: Union[str, Path]) -> Dict[str, str]:
with envfile_path.open(encoding="utf-8") as envfile_file:
result = parse_env_file(envfile_file.readlines())
if POE_DEBUG:
print(f" - Loaded Envfile from {envfile_path}")
print(f" + Loaded Envfile from {envfile_path}")
except ValueError as error:
message = error.args[0]
raise ExecutionError(
Expand Down
44 changes: 34 additions & 10 deletions poethepoet/env/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from .ui import PoeUi


class EnvVarsManager:
class EnvVarsManager(Mapping):
_config: "PoeConfig"
_ui: Optional["PoeUi"]
_vars: Dict[str, str]
Expand All @@ -24,13 +24,14 @@ def __init__( # TODO: check if we still need all these args!
base_env: Optional[Mapping[str, str]] = None,
cwd: Optional[Union[Path, str]] = None,
):
from ..helpers.git import GitRepo
from .cache import EnvFileCache

self._config = config
self._ui = ui
self.envfiles = (
# Reuse EnvFileCache from parent_env when possible
EnvFileCache(Path(config.project_dir), self._ui)
EnvFileCache(config.project_dir, self._ui)
if parent_env is None
else parent_env.envfiles
)
Expand All @@ -39,14 +40,33 @@ def __init__( # TODO: check if we still need all these args!
**(base_env or {}),
}

self._vars["POE_ROOT"] = str(self._config.project_dir)
self._vars["POE_ROOT"] = str(config.project_dir)

self.cwd = str(cwd or os.getcwd())
if "POE_CWD" not in self._vars:
self._vars["POE_CWD"] = self.cwd
self._vars["POE_PWD"] = self.cwd # Deprecated
self._vars["POE_PWD"] = self.cwd

self._git_repo = GitRepo(config.project_dir)

def __getitem__(self, key):
return self._vars[key]

def __iter__(self):
return iter(self._vars)

def __len__(self):
return len(self._vars)

def get(self, key: Any, /, default: Any = None) -> Optional[str]:
if key == "POE_GIT_DIR":
# This is a special case environment variable that is only set if requested
self._vars["POE_GIT_DIR"] = str(self._git_repo.path or "")

if key == "POE_GIT_ROOT":
# This is a special case environment variable that is only set if requested
self._vars["POE_GIT_ROOT"] = str(self._git_repo.main_path or "")

def get(self, key: str, default: Optional[str] = None) -> Optional[str]:
return self._vars.get(key, default)

def set(self, key: str, value: str):
Expand All @@ -65,7 +85,9 @@ def apply_env_config(
used if the associated key doesn't already have a value.
"""

vars_scope = dict(self._vars, POE_CONF_DIR=str(config_dir))
scoped_env = self.clone()
scoped_env.set("POE_CONF_DIR", str(config_dir))

if envfile:
if isinstance(envfile, str):
envfile = [envfile]
Expand All @@ -74,23 +96,25 @@ def apply_env_config(
self.envfiles.get(
config_working_dir.joinpath(
apply_envvars_to_template(
envfile_path, vars_scope, require_braces=True
envfile_path, scoped_env, require_braces=True
)
)
)
)

vars_scope = dict(self._vars, POE_CONF_DIR=str(config_dir))
scoped_env = self.clone()
scoped_env.set("POE_CONF_DIR", str(config_dir))

for key, value in (config_env or {}).items():
if isinstance(value, str):
value_str = value
elif key not in vars_scope:
elif key not in scoped_env:
value_str = value["default"]
else:
continue

self._vars[key] = apply_envvars_to_template(
value_str, vars_scope, require_braces=True
value_str, scoped_env, require_braces=True
)

def update(self, env_vars: Mapping[str, Any]):
Expand Down
66 changes: 66 additions & 0 deletions poethepoet/helpers/git.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import shutil
from pathlib import Path
from subprocess import PIPE, Popen
from typing import Optional, Tuple


class GitRepo:
def __init__(self, seed_path: Path):
self._seed_path = seed_path
self._path: Optional[Path] = None
self._main_path: Optional[Path] = None

@property
def path(self) -> Optional[Path]:
if self._path is None:
self._path = self._resolve_path()
return self._path

@property
def main_path(self) -> Optional[Path]:
if self._main_path is None:
self._main_path = self._resolve_main_path()
return self._main_path

def init(self):
self._exec("init")

def delete_git_dir(self):
shutil.rmtree(self._seed_path.joinpath(".git"))

def _resolve_path(self) -> Optional[Path]:
"""
Resolve the path of this git repo
"""
proc, captured_stdout = self._exec(
"rev-parse", "--show-superproject-working-tree", "--show-toplevel"
)
if proc.returncode == 0:
captured_lines = (
line.strip() for line in captured_stdout.decode().strip().split("\n")
)
longest_line = sorted((len(line), line) for line in captured_lines)[-1][1]
return Path(longest_line)
return None

def _resolve_main_path(self) -> Optional[Path]:
"""
Resolve the path of this git repo, unless this repo is a git submodule,
then resolve the path of the main git repo.
"""
proc, captured_stdout = self._exec(
"rev-parse", "--show-superproject-working-tree", "--show-toplevel"
)
if proc.returncode == 0:
return Path(captured_stdout.decode().strip().split("\n")[0])
return None

def _exec(self, *args: str) -> Tuple[Popen, bytes]:
proc = Popen(
["git", *args],
cwd=self._seed_path,
stdout=PIPE,
stderr=PIPE,
)
(captured_stdout, _) = proc.communicate()
return proc, captured_stdout
4 changes: 1 addition & 3 deletions poethepoet/task/cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,7 @@ def _resolve_commandline(self, context: "RunContext", env: "EnvVarsManager"):
working_dir = self.get_working_dir(env)

result = []
for cmd_token, has_glob in resolve_command_tokens(
command_lines[0], env.to_dict()
):
for cmd_token, has_glob in resolve_command_tokens(command_lines[0], env):
if has_glob:
# Resolve glob pattern from the working directory
result.extend([str(match) for match in working_dir.glob(cmd_token)])
Expand Down
Loading

0 comments on commit 0b023b8

Please sign in to comment.