Skip to content

Commit

Permalink
feat: use the plugin as backend hooksSigned-off-by: Frost Ming <me@fr…
Browse files Browse the repository at this point in the history
…ostming.com>

* feat: use the plugin as backend hooks

Signed-off-by: Frost Ming <me@frostming.com>
  • Loading branch information
frostming authored Dec 14, 2023
1 parent d0bbe43 commit 9bd2633
Show file tree
Hide file tree
Showing 27 changed files with 1,687 additions and 164 deletions.
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,6 @@ repos:
rev: v1.7.1
hooks:
- id: mypy
exclude: ^doc/src/.*\.py$
exclude: ^(doc/src/.*\.py|tests/data/.*\.py)$
additional_dependencies:
- pytest
48 changes: 44 additions & 4 deletions doc/src/usage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,10 @@ To avoid misuse, we recommend deciding whether to use this plugin based on your
Usage
*****

This plugin must be installed *and* activated explicitly.
This plugin can be used either in PDM CLI or build backends.

Installation
==================
Activate the plugin in PDM CLI
==============================

``pyproject.toml``

Expand All @@ -52,8 +52,10 @@ This registers the plugin with pdm.
To install the plugin locally, you need to run ``pdm install --plugins``.
This is only needed if you want to test the locking. On CI, plugins will be installed in the release job.

Alternatively, you can activate the plugin globally by running ``pdm self add pdm-build-locked``.

Activate the plugin
*******************
~~~~~~~~~~~~~~~~~~~

To enable locked builds, set the ``locked`` entry in ``pyproject.toml``:

Expand All @@ -72,3 +74,41 @@ To enable locked builds, set the ``locked`` entry in ``pyproject.toml``:

- run ``pdm build --locked``
- set ``PDM_BUILD_LOCKED`` env var to ``true``

Activate the plugin in build backends
=====================================

You can even use this plugin without PDM. This is enabled by build backend hooks.

Currently, both `pdm-backend <https://backend.pdm-project.org>` and `hatchling <https://hatch.pypa.io>` are supported.

pdm-backend
~~~~~~~~~~~

.. code-block::
:caption: pyproject.toml
[project]
dynamic = ["optional-dependencies"]
[build-system]
requires = ["pdm-backend", "pdm-build-locked"]
build-backend = "pdm.backend"
[tool.pdm.build]
locked = true
hatchling
~~~~~~~~~

.. code-block::
:caption: pyproject.toml
[project]
dynamic = ["optional-dependencies"]
[build-system]
requires = ["hatchling", "pdm-build-locked"]
build-backend = "hatchling.build"
[tool.hatch.metadata.hooks.build-locked]
379 changes: 365 additions & 14 deletions pdm.lock

Large diffs are not rendered by default.

21 changes: 18 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,19 @@ authors = [
{name = "sigma67", email = "sigma67.github@gmail.com"},
{name = "Frost Ming", email = "me@frostming.com"},
]
dependencies = []
dependencies = [
"tomli; python_version < \"3.11\""
]
requires-python = ">=3.8"

[project.entry-points.pdm]
pdm-build-locked = "pdm_build_locked:plugin"
build-locked = "pdm_build_locked.plugin:main"

[project.entry-points."pdm.build.hook"]
build-locked = "pdm_build_locked.backend:BuildLockedHook"

[project.entry-points.hatch]
build-locked = "pdm_build_locked.hatchling"

[build-system]
requires = ["pdm-backend"]
Expand All @@ -51,6 +59,13 @@ dev = [
"pkginfo>=1.9.6",
"pytest-cov>=4.1.0",
"pre-commit>=3.5.0",
"pdm-backend>=2.1.7",
"hatchling>=1.20.0",
"build>=1.0.3",
]
doc = [
"sphinx>=7.1.2",
"sphinx-rtd-theme>=2.0.0",
]

[tool.ruff]
Expand Down Expand Up @@ -83,7 +98,6 @@ files = [
]
mypy_path = "src"
show_error_codes = true
strict = true # https://mypy.readthedocs.io/en/stable/config_file.html#confval-strict

[tool.pytest.ini_options]
testpaths = ["tests"]
Expand All @@ -92,6 +106,7 @@ addopts = "--verbose --cov --cov-report=term --cov-report=html --cov-report=xml
[tool.coverage.run]
branch = true
source = ["src"]
omit = ["backend.py", "hatchling.py"]

[tool.coverage.report]
show_missing = true
22 changes: 0 additions & 22 deletions src/pdm_build_locked/__init__.py
Original file line number Diff line number Diff line change
@@ -1,22 +0,0 @@
"""
pdm-build-locked
A PDM plugin that adds locked dependencies to optional-dependencies on build
"""
from pdm.core import Core
from pdm.project.config import ConfigItem

from .command import BuildCommand


def plugin(core: Core) -> None:
"""register pdm plugin
Args:
core: pdm core
"""
core.register_command(BuildCommand)
core.add_config(
"build-locked.lock",
ConfigItem("Build this project with locked dependencies", False, env_var="PDM_BUILD_LOCKED"),
)
117 changes: 117 additions & 0 deletions src/pdm_build_locked/_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
from __future__ import annotations

import os
import sys
import warnings
from pathlib import Path
from typing import Any

if sys.version_info >= (3, 11):
import tomllib
else:
import tomli as tomllib # pragma: no cover


class UnsupportedRequirement(ValueError):
"""Requirement not complying with PEP 508"""


def requirement_dict_to_string(req_dict: dict[str, Any]) -> str:
"""Build a requirement string from a package item from pdm.lock
Args:
req_dict: The package item from pdm.lock
Returns:
A PEP 582 requirement string
"""
extra_string = f"[{','.join(extras)}]" if (extras := req_dict.get("extras", [])) else ""
version_string = f"=={version}" if (version := req_dict.get("version")) else ""
if "name" not in req_dict:
raise UnsupportedRequirement(f"Missing name in requirement: {req_dict}")
if "editable" in req_dict:
raise UnsupportedRequirement(f"Editable requirement is not allowed: {req_dict}")
if "path" in req_dict:
raise UnsupportedRequirement(f"Local path requirement is not allowed: {req_dict}")

url_string = ""
if "url" in req_dict:
url_string = f" @ {req_dict['url']}"
elif "ref" in req_dict: # VCS requirement
vcs, repo = next((k, v) for k, v in req_dict.items() if k in ("git", "svn", "bzr", "hg")) # pragma: no cover
url_string = f" @ {vcs}+{repo}@{req_dict.get('revision', req_dict['ref'])}"
if "subdirectory" in req_dict:
url_string = f"{url_string}#subdirectory={req_dict['subdirectory']}"

marker_string = f" ; {marker}" if (marker := req_dict.get("marker")) else ""
return f"{req_dict['name']}{extra_string}{version_string}{url_string}{marker_string}"


def get_locked_group_name(group: str) -> str:
"""
Get the name of the locked group corresponding to the original group
default dependencies: locked
optional dependency groups: {group}-locked
Args:
group: original group name
Returns:
locked group name
"""
group_name = "locked"
if group != "default":
group_name = f"{group}-{group_name}"

return group_name


def update_metadata_with_locked(metadata: dict[str, Any], root: Path) -> None: # pragma: no cover
"""Inplace update the metadata(pyproject.toml) with the locked dependencies.
Args:
metadata (dict[str, Any]): The metadata dictionary
root (Path): The path to the project root
Raises:
UnsupportedRequirement
"""
lockfile = root / "pdm.lock"
if "PDM_LOCKFILE" in os.environ:
lockfile = Path(os.environ["PDM_LOCKFILE"])
if not lockfile.exists():
warnings.warn("The lockfile doesn't exist, skip locking dependencies", UserWarning, stacklevel=1)
return
with lockfile.open("rb") as f:
lockfile_content = tomllib.load(f)

if "inherit_metadata" not in lockfile_content.get("metadata", {}).get("strategy", []):
warnings.warn(
"The lockfile doesn't support 'inherit_metadata' strategy, skip locking dependencies",
UserWarning,
stacklevel=1,
)
return

groups = ["default"]
optional_groups = list(metadata.get("optional-dependencies", {}))
locked_groups = lockfile_content.get("metadata", {}).get("groups", [])
groups.extend(optional_groups)
for group in groups:
locked_group = get_locked_group_name(group)
if locked_group in optional_groups:
# already exists, don't override
continue
if group not in locked_groups:
print(f"Group {group} is not stored in the lockfile, skip locking dependencies for it.")
continue
requirements: list[str] = []
for package in lockfile_content.get("package", []):
if group in package.get("groups", []):
try:
requirements.append(requirement_dict_to_string(package))
except UnsupportedRequirement as e:
print(f"Skipping unsupported requirement: {e}")
if not requirements:
raise UnsupportedRequirement(f"No valid PEP 508 requirements are found for group {group}")
metadata.setdefault("optional-dependencies", {})[locked_group] = requirements
27 changes: 27 additions & 0 deletions src/pdm_build_locked/backend.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from __future__ import annotations

import os
from typing import TYPE_CHECKING

from ._utils import update_metadata_with_locked

if TYPE_CHECKING:
from pdm.backend.hooks import BuildHookInterface
from pdm.backend.hooks.base import Context
else:
BuildHookInterface = object


class BuildLockedHook(BuildHookInterface):
def pdm_build_hook_enabled(self, context: Context) -> bool:
if os.getenv("PDM_BUILD_LOCKED", "false") != "false":
return True
return context.config.build_config.get("locked", False)

def pdm_build_initialize(self, context: Context) -> None:
static_fields = list(context.config.metadata)
update_metadata_with_locked(context.config.metadata, context.root)
new_fields = set(context.config.metadata) - set(static_fields)
for field in new_fields:
if field in context.config.metadata.get("dynamic", []):
context.config.metadata["dynamic"].remove(field)
Loading

0 comments on commit 9bd2633

Please sign in to comment.