# -*- coding: utf-8 -*-
"""
Dependency Management Automation for Python Projects.
This module provides comprehensive automation for managing project dependencies
using `Poetry <<https://python-poetry.org/>`_, including locking, installation,
and export ``requirements.txt`` functionalities.
"""
import typing as T
import json
import subprocess
import dataclasses
from pathlib import Path
from .vendor.emoji import Emoji
from .vendor.better_pathlib import temp_cwd
from .logger import logger
from .helpers import sha256_of_bytes, print_command
if T.TYPE_CHECKING: # pragma: no cover
from .define import PyWf
[docs]
@dataclasses.dataclass
class PyWfDeps:
"""
Namespace class for managing project dependencies using Poetry.
"""
def _run_poetry_command(
self: "PyWf",
args: T.List[str],
real_run: bool,
quiet: bool,
):
args = [f"{self.path_bin_poetry}", *args]
if quiet: # pragma: no cover
args.append("--quiet")
self.run_command(args, real_run)
@logger.emoji_block(
msg="Resolve Dependencies Tree",
emoji=Emoji.install,
)
def _poetry_lock(
self: "PyWf",
real_run: bool = True,
quiet: bool = False,
):
"""
Lock project dependencies defined in pyproject.toml.
Resolves and writes exact dependency versions to ``poetry.lock`` file.
This ensures reproducible builds across different environments.
You have to run this everytime you changed the ``pyproject.toml`` file.
And you should commit the latest ``poetry.lock`` file to git.
Run:
.. code-block:: bash
poetry lock
Ref:
- poetry lock: https://python-poetry.org/docs/cli/#lock
"""
return self._run_poetry_command(
args=["lock"],
real_run=real_run,
quiet=quiet,
)
[docs]
def poetry_lock(
self: "PyWf",
real_run: bool = True,
verbose: bool = True,
): # pragma: no cover
with logger.disabled(disable=not verbose):
return self._poetry_lock(
real_run=real_run,
quiet=not verbose,
)
poetry_lock.__doc__ = _poetry_lock.__doc__
@logger.emoji_block(
msg="Install package source code without any dependencies",
emoji=Emoji.install,
)
def _poetry_install_only_root(
self: "PyWf",
real_run: bool = True,
quiet: bool = False,
):
"""
Install only the package source code in editable mode.
Skips installing any external dependencies, useful for development
and testing package structure.
Run:
.. code-block:: bash
poetry install --only-root
Ref:
- poetry install: https://python-poetry.org/docs/cli/#install
"""
return self._run_poetry_command(
args=["install", "--only-root"],
real_run=real_run,
quiet=quiet,
)
[docs]
def poetry_install_only_root(
self: "PyWf",
real_run: bool = True,
verbose: bool = True,
): # pragma: no cover
with logger.disabled(disable=not verbose):
return self._poetry_install_only_root(
real_run=real_run,
quiet=not verbose,
)
poetry_install_only_root.__doc__ = _poetry_install_only_root.__doc__
@logger.emoji_block(
msg="Install main dependencies and Package itself",
emoji=Emoji.install,
)
def _poetry_install(
self: "PyWf",
real_run: bool = True,
quiet: bool = False,
):
"""
Install main dependencies and the package in editable mode.
Installs core project dependencies without development or optional groups.
Run:
.. code-block:: bash
poetry install
Ref:
- poetry install: https://python-poetry.org/docs/cli/#install
"""
return self._run_poetry_command(
args=["install"],
real_run=real_run,
quiet=quiet,
)
[docs]
def poetry_install(
self: "PyWf",
real_run: bool = True,
verbose: bool = True,
):
with logger.disabled(disable=not verbose):
return self._poetry_install(
real_run=real_run,
quiet=not verbose,
)
poetry_install.__doc__ = _poetry_install.__doc__
@logger.emoji_block(
msg="Install dev dependencies",
emoji=Emoji.install,
)
def _poetry_install_dev(
self: "PyWf",
real_run: bool = True,
quiet: bool = False,
):
"""
Install development dependencies. Adds dependencies from the
``[tool.poetry.group.dev.dependencies]`` group to the project environment.
Run:
.. code-block:: bash
poetry install --extras dev
Ref:
- poetry install: https://python-poetry.org/docs/cli/#install
"""
return self._run_poetry_command(
args=["install", "--extras", "dev"],
real_run=real_run,
quiet=quiet,
)
[docs]
def poetry_install_dev(
self: "PyWf",
real_run: bool = True,
verbose: bool = True,
):
with logger.disabled(disable=not verbose):
return self._poetry_install_dev(
real_run=real_run,
quiet=not verbose,
)
poetry_install_dev.__doc__ = _poetry_install_dev.__doc__
@logger.emoji_block(
msg="Install test dependencies",
emoji=Emoji.install,
)
def _poetry_install_test(
self: "PyWf",
real_run: bool = True,
quiet: bool = False,
):
"""
Install test dependencies. Adds dependencies from the
``[tool.poetry.group.test.dependencies]`` group to the project environment.
Run:
.. code-block:: bash
poetry install --extras test
Ref:
- poetry install: https://python-poetry.org/docs/cli/#install
"""
return self._run_poetry_command(
args=["install", "--extras", "test"],
real_run=real_run,
quiet=quiet,
)
[docs]
def poetry_install_test(
self: "PyWf",
real_run: bool = True,
verbose: bool = True,
): # pragma: no cover
with logger.disabled(disable=not verbose):
return self._poetry_install_test(
real_run=real_run,
quiet=not verbose,
)
poetry_install_test.__doc__ = _poetry_install_test.__doc__
@logger.emoji_block(
msg="Install doc dependencies",
emoji=Emoji.install,
)
def _poetry_install_doc(
self: "PyWf",
real_run: bool = True,
quiet: bool = False,
):
"""
Install documentation build dependencies. Adds dependencies from the
``[tool.poetry.group.doc.dependencies]`` group to the project environment.
Run:
.. code-block:: bash
poetry install --extras doc
Ref:
- poetry install: https://python-poetry.org/docs/cli/#install
"""
return self._run_poetry_command(
args=["install", "--extras", "doc"],
real_run=real_run,
quiet=quiet,
)
[docs]
def poetry_install_doc(
self: "PyWf",
real_run: bool = True,
verbose: bool = True,
):
with logger.disabled(disable=not verbose):
return self._poetry_install_doc(
real_run=real_run,
quiet=not verbose,
)
poetry_install_doc.__doc__ = _poetry_install_doc.__doc__
@logger.emoji_block(
msg="Install automation dependencies",
emoji=Emoji.install,
)
def _poetry_install_auto(
self: "PyWf",
real_run: bool = True,
quiet: bool = False,
):
"""
Install automation dependencies. Adds dependencies from the
``[tool.poetry.group.auto.dependencies]`` group to the project environment.
Run:
.. code-block:: bash
poetry install --extras auto
Ref:
- poetry install: https://python-poetry.org/docs/cli/#install
"""
return self._run_poetry_command(
args=["install", "--extras", "auto"],
real_run=real_run,
quiet=quiet,
)
[docs]
def poetry_install_auto(
self: "PyWf",
real_run: bool = True,
verbose: bool = True,
):
with logger.disabled(disable=not verbose):
return self._poetry_install_auto(
real_run=real_run,
quiet=not verbose,
)
poetry_install_auto.__doc__ = _poetry_install_auto.__doc__
@logger.emoji_block(
msg="Install all dependencies for dev, test, doc",
emoji=Emoji.install,
)
def _poetry_install_all(
self: "PyWf",
real_run: bool = True,
quiet: bool = False,
):
"""
Install all dependency groups.
Adds dependencies from all configured groups to the project environment.
Run:
.. code-block:: bash
poetry install --all-extras
Ref:
- poetry install: https://python-poetry.org/docs/cli/#install
"""
return self._run_poetry_command(
args=["install", "--all-extras"],
real_run=real_run,
quiet=quiet,
)
[docs]
def poetry_install_all(
self: "PyWf",
real_run: bool = True,
verbose: bool = True,
):
with logger.disabled(disable=not verbose):
return self._poetry_install_all(
real_run=real_run,
quiet=not verbose,
)
poetry_install_all.__doc__ = _poetry_install_all.__doc__
def _do_we_need_poetry_export(
self: "PyWf",
current_poetry_lock_hash: str,
) -> bool:
"""
The ``poetry export`` command is resource-intensive, so we use a
caching mechanism to avoid unnecessary executions.
Each time we run :meth:`PyWfDeps._poetry_export`, it calculates the
SHA-256 hash of the current ``poetry.lock`` file and writes it to
a cache file named ``poetry-lock-hash.json``, located at the root of the repository.
Before exporting, the function compares the current hash of ``poetry.lock``
with the value stored in the cache file. If the hashes differ, it indicates
that ``poetry.lock`` has changed, and we should run :meth:`PyWfDeps._poetry_export` again.
An example of the ``poetry-lock-hash.json`` file content::
{
"hash": "sha256-hash-of-the-poetry.lock-file",
"description": "DON'T edit this file manually!"
}
Ref:
- poetry export: https://python-poetry.org/docs/cli/#export
:param current_poetry_lock_hash: the sha256 hash of the current ``poetry.lock`` file
"""
if self.path_poetry_lock_hash_json.exists():
# read the previous poetry lock hash from cache file
cached_poetry_lock_hash = json.loads(
self.path_poetry_lock_hash_json.read_text()
)["hash"]
return current_poetry_lock_hash != cached_poetry_lock_hash
else:
# do poetry export if the cache file not found
return True
def _poetry_export_main(
self: "PyWf",
with_hash: bool = False,
real_run: bool = True,
):
"""
Export main project dependencies to ``requirements.txt``.
:param with_hash: whether to include the hash of the dependencies in the
requirements.txt file.
"""
self.path_requirements.unlink(missing_ok=True)
args = [
f"{self.path_bin_poetry}",
"export",
"--format",
"requirements.txt",
"--output",
f"{self.path_requirements}",
]
if with_hash is False:
args.append("--without-hashes")
self.run_command(args, real_run)
def _poetry_export_group(
self: "PyWf",
group: str,
path: Path,
with_hash: bool = False,
real_run: bool = True,
):
"""
Export specific dependency group to ``requirements-{group}.txt``.
Ref: https://github.com/python-poetry/poetry-plugin-export
:param group: dependency group name, for example dev dependencies are defined
in the ``[tool.poetry.group.dev]`` and ``[tool.poetry.group.dev.dependencies]``
sections of he ``pyproject.toml`` file.
:param path: the path to the exported ``requirements.txt`` file.
:param with_hash: whether to include the hash of the dependencies in the
``requirements.txt`` file.
"""
if real_run:
path.unlink(missing_ok=True)
args = [
f"{self.path_bin_poetry}",
"export",
"--format",
"requirements.txt",
"--output",
f"{path}",
"--extras",
]
args.append(group)
if with_hash is False:
args.append("--without-hashes")
self.run_command(args, real_run)
def _poetry_export_logic(
self: "PyWf",
current_poetry_lock_hash: str,
with_hash: bool = False,
real_run: bool = True,
):
"""
Run ``poetry export --format requirements.txt ...`` command and write
the sha256 hash of the current ``poetry.lock`` file to the cache file.
Run:
.. code-block:: bash
poetry export --format requirements.txt --output requirements.txt --without-hashes
poetry export --format requirements.txt --output requirements-dev.txt --extras dev --without-hashes
poetry export --format requirements.txt --output requirements-test.txt --extras test --without-hashes
poetry export --format requirements.txt --output requirements-doc.txt --extras doc --without-hashes
poetry export --format requirements.txt --output requirements-automation.txt --extras auto --without-hashes
:param current_poetry_lock_hash: the sha256 hash of the current ``poetry.lock`` file
:param with_hash: whether to include the hash of the dependencies in the
requirements.txt file.
"""
# export the main dependencies
self._poetry_export_main(with_hash=with_hash, real_run=real_run)
# export dev, test, doc, auto dependencies
for group, path in [
("dev", self.path_requirements_dev),
("test", self.path_requirements_test),
("doc", self.path_requirements_doc),
("auto", self.path_requirements_automation),
]:
self._poetry_export_group(
group=group,
path=path,
with_hash=with_hash,
real_run=real_run,
)
# write the ``poetry.lock`` hash to the cache file
if real_run:
self.path_poetry_lock_hash_json.write_text(
json.dumps(
{
"hash": current_poetry_lock_hash,
"description": (
"DON'T edit this file manually! This file is the cache of "
"the poetry.lock file hash. It is used to avoid unnecessary "
"expansive 'poetry export ...' command."
),
},
indent=4,
)
)
@logger.emoji_block(
msg="Export all dependencies to requirements-***.txt",
emoji=Emoji.install,
)
def _poetry_export(
self: "PyWf",
with_hash: bool = False,
real_run: bool = True,
quiet: bool = False,
) -> bool:
"""
:return: ``True`` if ``poetry export`` is executed, ``False`` if not.
"""
poetry_lock_hash = sha256_of_bytes(self.path_poetry_lock.read_bytes())
if self._do_we_need_poetry_export(poetry_lock_hash):
self._poetry_export_logic(
current_poetry_lock_hash=poetry_lock_hash,
with_hash=with_hash,
real_run=real_run,
)
return True
else:
logger.info("already did, do nothing")
return False
[docs]
def poetry_export(
self: "PyWf",
with_hash: bool = False,
real_run: bool = True,
verbose: bool = True,
):
with logger.disabled(disable=not verbose):
flag = self._poetry_export(
with_hash=with_hash,
real_run=real_run,
quiet=not verbose,
)
return flag
poetry_export.__doc__ = _poetry_export_logic.__doc__