pyproject-external 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,35 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # SPDX-FileCopyrightText: 2023 Quansight Labs
3
+ """
4
+ pyproject-external - Utilities to work with PEP 725 `[external]` metadata
5
+ """
6
+
7
+ from ._config import Config # noqa
8
+ from ._external import External # noqa
9
+ from ._registry import Registry, Ecosystems, Mapping, default_ecosystems, remote_mapping # noqa
10
+ from ._system import ( # noqa
11
+ find_ecosystem_for_package_manager,
12
+ detect_ecosystem_and_package_manager,
13
+ activated_conda_env,
14
+ )
15
+ from ._url import DepURL # noqa
16
+ from ._version import __version__
17
+
18
+ __all__ = [
19
+ "__version__",
20
+ "Config",
21
+ "DepURL",
22
+ "Ecosystems",
23
+ "External",
24
+ "Mapping",
25
+ "Registry",
26
+ "activated_conda_env",
27
+ "find_ecosystem_for_package_manager",
28
+ "detect_ecosystem_and_package_manager",
29
+ "default_ecosystems",
30
+ "remote_mapping",
31
+ ]
32
+
33
+
34
+ def __dir__() -> list[str]:
35
+ return __all__
@@ -0,0 +1,9 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # SPDX-FileCopyrightText: 2025 Quansight Labs
3
+
4
+ if __name__ == "__main__":
5
+ import sys
6
+
7
+ from ._cli import app
8
+
9
+ sys.exit(app())
@@ -0,0 +1,4 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # SPDX-FileCopyrightText: 2025 Quansight Labs
3
+
4
+ from .app import app as app
@@ -0,0 +1,39 @@
1
+ import tarfile
2
+ from enum import Enum
3
+ from pathlib import Path
4
+
5
+ import typer
6
+
7
+
8
+ def _read_pyproject_from_sdist(path: Path) -> str:
9
+ with tarfile.open(path) as tar:
10
+ for info in tar.getmembers():
11
+ name = info.name
12
+ if "/" in name and name.split("/")[-1] == "pyproject.toml":
13
+ return tar.extractfile(info).read().decode()
14
+ raise ValueError("Could not read pyproject.toml file from sdist")
15
+
16
+
17
+ def _pyproject_text(package: Path) -> str:
18
+ if package.is_file():
19
+ if not package.name.lower().endswith(".tar.gz"):
20
+ raise typer.BadParameter(f"Given package '{package}' is a file, but not a sdist.")
21
+ return _read_pyproject_from_sdist(package)
22
+ if package.is_dir():
23
+ return (package / "pyproject.toml").read_text()
24
+ raise typer.BadParameter(f"Package {package} is not a valid path.")
25
+
26
+
27
+ class _Installers(str, Enum):
28
+ pip = "pip"
29
+ uv = "uv"
30
+
31
+
32
+ class NotOnCIError(RuntimeError):
33
+ def __init__(self):
34
+ super().__init__(
35
+ "This tool should only be used in CI or ephemeral environments!\n\n"
36
+ "It will likely install system packages as a side effect of providing the "
37
+ "external dependencies required to build the wheels.\n\n"
38
+ "If you understand the risks, set CI=1 to override."
39
+ )
@@ -0,0 +1,36 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # SPDX-FileCopyrightText: 2025 Quansight Labs
3
+ """
4
+ CLI to work with PEP 725 external metadata.
5
+ """
6
+
7
+ import logging
8
+
9
+ import typer
10
+ from rich.console import Console
11
+ from rich.logging import RichHandler
12
+
13
+ from .build import app as _install
14
+ from .install import app as _build
15
+ from .prepare import app as _prepare
16
+ from .show import app as _show
17
+
18
+ app = typer.Typer(
19
+ help=__doc__,
20
+ no_args_is_help=True,
21
+ add_completion=False,
22
+ )
23
+ app.add_typer(_show)
24
+ app.add_typer(_build)
25
+ app.add_typer(_install)
26
+ app.add_typer(_prepare)
27
+
28
+ logging.basicConfig(
29
+ level=logging.INFO,
30
+ format="%(message)s",
31
+ datefmt="[%X]",
32
+ handlers=[RichHandler(console=Console(stderr=True))],
33
+ )
34
+
35
+ if __name__ == "__main__":
36
+ app()
@@ -0,0 +1,120 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # SPDX-FileCopyrightText: 2025 Quansight Labs
3
+ """
4
+ Build a wheel for the given sdist or project.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import os
11
+ import subprocess
12
+ import sys
13
+ import tarfile
14
+ from contextlib import nullcontext
15
+ from pathlib import Path
16
+ from tempfile import TemporaryDirectory
17
+ from typing import Annotated
18
+
19
+ try:
20
+ import tomllib
21
+ except ImportError:
22
+ import tomli as tomllib
23
+
24
+ import typer
25
+
26
+ from .. import (
27
+ Config,
28
+ External,
29
+ activated_conda_env,
30
+ detect_ecosystem_and_package_manager,
31
+ find_ecosystem_for_package_manager,
32
+ )
33
+ from ._utils import NotOnCIError, _Installers, _pyproject_text
34
+
35
+ log = logging.getLogger(__name__)
36
+ app = typer.Typer()
37
+
38
+
39
+ @app.command(
40
+ help=__doc__,
41
+ context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
42
+ )
43
+ def build(
44
+ package: Annotated[
45
+ str,
46
+ typer.Argument(
47
+ help="Package to build wheel for."
48
+ "It can be a path to a pyproject.toml-containing directory, "
49
+ "or a source distribution."
50
+ ),
51
+ ],
52
+ package_manager: Annotated[
53
+ str,
54
+ typer.Option(
55
+ help="If given, use this package manager to install the external dependencies "
56
+ "rather than the auto-detected one."
57
+ ),
58
+ ] = Config.load_user_config().preferred_package_manager or "",
59
+ outdir: Annotated[
60
+ str | None,
61
+ typer.Option(help="Output directory for the wheel. Defaults to working directory"),
62
+ ] = None,
63
+ build_installer: Annotated[
64
+ _Installers,
65
+ typer.Option(
66
+ help="Which installer tool should be used to provide the isolated 'build' venv"
67
+ ),
68
+ ] = _Installers.pip,
69
+ unknown_args: typer.Context = typer.Option(()),
70
+ ) -> None:
71
+ if not os.environ.get("CI"):
72
+ raise NotOnCIError()
73
+
74
+ package = Path(package)
75
+ pyproject_text = _pyproject_text(package)
76
+ pyproject = tomllib.loads(pyproject_text)
77
+ external = External.from_pyproject_data(pyproject)
78
+ external.validate()
79
+
80
+ if package_manager:
81
+ ecosystem = find_ecosystem_for_package_manager(package_manager)
82
+ else:
83
+ ecosystem, package_manager = detect_ecosystem_and_package_manager()
84
+ log.info("Detected ecosystem '%s' for package manager '%s'", ecosystem, package_manager)
85
+
86
+ install_external_cmd = external.install_command(ecosystem, package_manager=package_manager)
87
+ build_cmd = [
88
+ sys.executable,
89
+ "-m",
90
+ "build",
91
+ "--wheel",
92
+ "--outdir",
93
+ outdir or os.getcwd(),
94
+ "--installer",
95
+ build_installer,
96
+ *unknown_args.args,
97
+ ]
98
+ try:
99
+ # 1. Install external dependencies
100
+ subprocess.run(install_external_cmd, check=True)
101
+ # 2. Build wheel
102
+ with (
103
+ activated_conda_env(package_manager=package_manager)
104
+ if ecosystem == "conda-forge"
105
+ else nullcontext(os.environ) as env
106
+ ):
107
+ if package.is_file():
108
+ with TemporaryDirectory() as tmp:
109
+ with tarfile.open(package) as tar:
110
+ tar.extractall(tmp, filter="data")
111
+ tmp = Path(tmp)
112
+ if (tmp / "pyproject.toml").is_file():
113
+ extracted_package = tmp
114
+ else:
115
+ extracted_package = next(tmp.glob("*"))
116
+ subprocess.run([*build_cmd, extracted_package], check=True, env=env)
117
+ else:
118
+ subprocess.run([*build_cmd, package], check=True, env=env)
119
+ except subprocess.CalledProcessError as exc:
120
+ sys.exit(exc.returncode) # avoid unnecessary typer pretty traceback
@@ -0,0 +1,97 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # SPDX-FileCopyrightText: 2025 Quansight Labs
3
+ """
4
+ Install a project in the given location. Wheels will be built as needed.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import os
11
+ import subprocess
12
+ import sys
13
+ from contextlib import nullcontext
14
+ from pathlib import Path
15
+ from typing import Annotated
16
+
17
+ try:
18
+ import tomllib
19
+ except ImportError:
20
+ import tomli as tomllib
21
+
22
+ import typer
23
+
24
+ from .. import (
25
+ Config,
26
+ External,
27
+ activated_conda_env,
28
+ detect_ecosystem_and_package_manager,
29
+ find_ecosystem_for_package_manager,
30
+ )
31
+ from ._utils import NotOnCIError, _Installers, _pyproject_text
32
+
33
+ log = logging.getLogger(__name__)
34
+ app = typer.Typer()
35
+
36
+
37
+ @app.command(
38
+ help=__doc__,
39
+ context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
40
+ )
41
+ def install(
42
+ package: Annotated[
43
+ str,
44
+ typer.Argument(
45
+ help="Package to build wheel for."
46
+ "It can be a path to a pyproject.toml-containing directory, "
47
+ "or a source distribution."
48
+ ),
49
+ ],
50
+ package_manager: Annotated[
51
+ str,
52
+ typer.Option(
53
+ help="If given, use this package manager to install the external dependencies "
54
+ "rather than the auto-detected one."
55
+ ),
56
+ ] = Config.load_user_config().preferred_package_manager or "",
57
+ installer: Annotated[
58
+ _Installers,
59
+ typer.Option(help="Which tool should be used to install the package"),
60
+ ] = _Installers.pip,
61
+ unknown_args: typer.Context = typer.Option(()),
62
+ ) -> None:
63
+ if not os.environ.get("CI"):
64
+ raise NotOnCIError()
65
+
66
+ package = Path(package)
67
+ pyproject_text = _pyproject_text(package)
68
+ pyproject = tomllib.loads(pyproject_text)
69
+ external = External.from_pyproject_data(pyproject)
70
+ external.validate()
71
+
72
+ if package_manager:
73
+ ecosystem = find_ecosystem_for_package_manager(package_manager)
74
+ else:
75
+ ecosystem, package_manager = detect_ecosystem_and_package_manager()
76
+ log.info("Detected ecosystem '%s' for package manager '%s'", ecosystem, package_manager)
77
+
78
+ install_external_cmd = external.install_command(ecosystem, package_manager=package_manager)
79
+ if installer == _Installers.pip:
80
+ install_cmd = [sys.executable, "-m", "pip", "install"]
81
+ elif installer == _Installers.uv:
82
+ install_cmd = ["uv", "pip", "install", "--python", sys.executable]
83
+ else:
84
+ raise ValueError(f"Unrecognized 'installer': {installer}")
85
+
86
+ try:
87
+ # 1. Install external dependencies
88
+ subprocess.run(install_external_cmd, check=True)
89
+ # 2. Build wheel
90
+ with (
91
+ activated_conda_env(package_manager=package_manager)
92
+ if ecosystem == "conda-forge"
93
+ else nullcontext(os.environ) as env
94
+ ):
95
+ subprocess.run([*install_cmd, *unknown_args.args, package], check=True, env=env)
96
+ except subprocess.CalledProcessError as exc:
97
+ sys.exit(exc.returncode) # avoid unnecessary typer pretty traceback
@@ -0,0 +1,66 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # SPDX-FileCopyrightText: 2025 Quansight Labs
3
+ """
4
+ Prepare a package for building with [external] metadata
5
+ by downloading and patching its most recent sdist.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import os
12
+ from pathlib import Path
13
+ from tempfile import TemporaryDirectory
14
+ from typing import Annotated
15
+
16
+ import typer
17
+
18
+ from .._sdist import (
19
+ append_external_metadata,
20
+ apply_patches,
21
+ create_new_sdist,
22
+ download_sdist,
23
+ untar_sdist,
24
+ )
25
+
26
+ log = logging.getLogger(__name__)
27
+ app = typer.Typer()
28
+
29
+
30
+ @app.command(help=__doc__)
31
+ def prepare(
32
+ package_name: Annotated[
33
+ str,
34
+ typer.Argument(help="PyPI package name to download and patch."),
35
+ ],
36
+ external_metadata_dir: Annotated[
37
+ str,
38
+ typer.Option(
39
+ help="Search this directory to find a '<package_name>.toml' "
40
+ "file that contains an '[external]' table.",
41
+ ),
42
+ ] = "external_metadata",
43
+ patches_dir: Annotated[
44
+ str,
45
+ typer.Option(
46
+ help="Search this directory to find a '<package_name>.py' "
47
+ "script that will run additional patches on the sdist contents.",
48
+ ),
49
+ ] = "patches",
50
+ out_dir: Annotated[
51
+ str,
52
+ typer.Option(help="Directory where the patched sdist will be written to."),
53
+ ] = "sdist",
54
+ ) -> None:
55
+ with TemporaryDirectory() as tmp:
56
+ tmp = Path(tmp)
57
+ fname_sdist = download_sdist(package_name, tmp)
58
+ fname_pyproject_toml = untar_sdist(fname_sdist, tmp)
59
+ append_external_metadata(
60
+ fname_pyproject_toml,
61
+ package_name,
62
+ patches_dir=external_metadata_dir,
63
+ )
64
+ apply_patches(package_name, fname_pyproject_toml.parent, patches_dir=patches_dir)
65
+ Path(out_dir).mkdir(parents=True, exist_ok=True)
66
+ create_new_sdist(fname_sdist, tmp, out_dir or os.getcwd())
@@ -0,0 +1,105 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # SPDX-FileCopyrightText: 2023 Quansight Labs
3
+ """
4
+ Query PEP 725 [external] metadata from pyproject.toml or source distributions.
5
+ """
6
+
7
+ import logging
8
+ import shlex
9
+ from enum import Enum
10
+ from pathlib import Path
11
+ from typing import Annotated
12
+
13
+ try:
14
+ import tomllib
15
+ except ImportError:
16
+ import tomli as tomllib
17
+
18
+ import tomli_w
19
+ import typer
20
+ from rich import print as rprint
21
+ from rich.markup import escape
22
+
23
+ # Only import from __init__ to make sure the only uses the public interface
24
+ from .. import (
25
+ Config,
26
+ External,
27
+ detect_ecosystem_and_package_manager,
28
+ find_ecosystem_for_package_manager,
29
+ )
30
+ from ._utils import _pyproject_text
31
+
32
+ log = logging.getLogger(__name__)
33
+ app = typer.Typer()
34
+
35
+
36
+ class _OutputChoices(Enum):
37
+ RAW = "raw"
38
+ NORMALIZED = "normalized"
39
+ MAPPED_TABLE = "mapped"
40
+ MAPPED_LIST = "mapped-list"
41
+ COMMAND = "command"
42
+
43
+
44
+ @app.command(help=__doc__)
45
+ def show(
46
+ package: Annotated[
47
+ str,
48
+ typer.Argument(
49
+ help="Package to analyze. It can be a path to a pyproject.toml-containing directory,"
50
+ " or a source distribution."
51
+ ),
52
+ ],
53
+ validate: Annotated[
54
+ bool,
55
+ typer.Option(help="Validate external dependencies against central registry."),
56
+ ] = False,
57
+ output: Annotated[
58
+ _OutputChoices,
59
+ typer.Option(
60
+ help="Choose output format. 'raw' prints the TOML table as is. "
61
+ "'normalized' processes the 'dep:' URLs before printing them. "
62
+ "'mapped' prints the dependencies mapped to the given ecosystem. "
63
+ "'command' prints the install command for the given package manager."
64
+ ),
65
+ ] = _OutputChoices.RAW.value,
66
+ package_manager: Annotated[
67
+ str,
68
+ typer.Option(help="If given, use this package manager rather than the auto-detected one."),
69
+ ] = Config.load_user_config().preferred_package_manager or "",
70
+ ) -> None:
71
+ package = Path(package)
72
+ pyproject_text = _pyproject_text(package)
73
+ pyproject = tomllib.loads(pyproject_text)
74
+ raw_external = pyproject.get("external")
75
+ if not raw_external:
76
+ raise typer.BadParameter("Package's pyproject.toml does not contain an 'external' table.")
77
+
78
+ external = External.from_pyproject_data(pyproject)
79
+ if validate:
80
+ external.validate()
81
+
82
+ if output == _OutputChoices.RAW:
83
+ rprint(escape(tomli_w.dumps({"external": raw_external}).rstrip()))
84
+ return
85
+
86
+ if output == _OutputChoices.NORMALIZED:
87
+ rprint(escape(tomli_w.dumps(external.to_dict())))
88
+ return
89
+
90
+ if package_manager:
91
+ ecosystem = find_ecosystem_for_package_manager(package_manager)
92
+ else:
93
+ ecosystem, package_manager = detect_ecosystem_and_package_manager()
94
+ log.info("Detected ecosystem '%s' for package manager '%s'", ecosystem, package_manager)
95
+ if output == _OutputChoices.MAPPED_TABLE:
96
+ mapped_dict = external.to_dict(mapped_for=ecosystem, package_manager=package_manager)
97
+ rprint(escape(tomli_w.dumps(mapped_dict)))
98
+ # The following outputs might be used in shell substitutions like $(), so use print()
99
+ # directly. rich's print will hard-wrap the line and break the output.
100
+ elif output == _OutputChoices.COMMAND:
101
+ print(shlex.join(external.install_command(ecosystem, package_manager=package_manager)))
102
+ elif output == _OutputChoices.MAPPED_LIST:
103
+ print(shlex.join(external.map_dependencies(ecosystem, package_manager=package_manager)))
104
+ else:
105
+ raise typer.BadParameter(f"Unknown value for --output: {output}")
@@ -0,0 +1,36 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # SPDX-FileCopyrightText: 2025 Quansight Labs
3
+ import os
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+ try:
8
+ import tomllib
9
+ except ImportError:
10
+ import tomli as tomllib
11
+
12
+ from platformdirs import user_config_dir
13
+
14
+ from ._constants import APP_AUTHOR, APP_CONFIG_FILENAME, APP_NAME
15
+
16
+
17
+ def _get_config_directory() -> Path:
18
+ if pyproject_external_config := os.environ.get("PYPROJECT_EXTERNAL_CONFIG"):
19
+ return Path(pyproject_external_config)
20
+ return Path(user_config_dir(appname=APP_NAME, appauthor=APP_AUTHOR))
21
+
22
+
23
+ def _get_config_file() -> Path:
24
+ return _get_config_directory() / APP_CONFIG_FILENAME
25
+
26
+
27
+ @dataclass(frozen=True, kw_only=True)
28
+ class Config:
29
+ preferred_package_manager: str | None = None
30
+
31
+ @classmethod
32
+ def load_user_config(cls) -> "Config":
33
+ config_file = _get_config_file()
34
+ if config_file.is_file():
35
+ return cls(**tomllib.loads(_get_config_file().read_text()))
36
+ return cls()
@@ -0,0 +1,30 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # SPDX-FileCopyrightText: 2025 Quansight Labs
3
+ from typing import Final
4
+
5
+ APP_NAME: Final[str] = "pyproject-external"
6
+ APP_AUTHOR: Final[str] = "pyproject-external"
7
+ APP_CONFIG_FILENAME: Final[str] = "config.toml"
8
+ DEFAULT_ECOSYSTEMS_SCHEMA_URL: Final[str] = (
9
+ "https://raw.githubusercontent.com/jaimergp/external-metadata-mappings/main/"
10
+ "schemas/known-ecosystems.schema.json"
11
+ )
12
+ DEFAULT_ECOSYSTEMS_URL: Final[str] = (
13
+ "https://raw.githubusercontent.com/jaimergp/external-metadata-mappings/main/"
14
+ "data/known-ecosystems.json"
15
+ )
16
+ DEFAULT_MAPPING_SCHEMA_URL: Final[str] = (
17
+ "https://raw.githubusercontent.com/jaimergp/external-metadata-mappings/main/"
18
+ "schemas/external-mapping.schema.json"
19
+ )
20
+ DEFAULT_MAPPING_URL_TEMPLATE: Final[str] = (
21
+ "https://raw.githubusercontent.com/jaimergp/external-metadata-mappings/main/"
22
+ "data/{}.mapping.json"
23
+ )
24
+ DEFAULT_REGISTRY_SCHEMA_URL: Final[str] = (
25
+ "https://raw.githubusercontent.com/jaimergp/external-metadata-mappings/main/"
26
+ "schemas/central-registry.schema.json"
27
+ )
28
+ DEFAULT_REGISTRY_URL: Final[str] = (
29
+ "https://raw.githubusercontent.com/jaimergp/external-metadata-mappings/main/data/registry.json"
30
+ )