vendoring 1.3.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.
vendoring/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """A command line tool, to simplify vendoring pure Python dependencies."""
2
+
3
+ # WARNING: vendoring is a CLI-only tool. DO NOT `import vendoring` or anything
4
+ # within this namespace.
5
+
6
+ __version__ = "1.3.0"
vendoring/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from . import cli
2
+
3
+ if __name__ == "__main__":
4
+ cli.main()
vendoring/cli.py ADDED
@@ -0,0 +1,94 @@
1
+ import sys
2
+ from pathlib import Path
3
+ from typing import Callable, List, NamedTuple, Optional
4
+
5
+ import click
6
+
7
+ from vendoring.configuration import load_configuration
8
+ from vendoring.errors import VendoringError
9
+ from vendoring.interactive import interactive_updates
10
+ from vendoring.sync import run_sync
11
+ from vendoring.tasks.update import update_requirements
12
+ from vendoring.ui import UI
13
+
14
+ _EntryPoint = Callable[..., None]
15
+ _Param = Callable[[_EntryPoint], _EntryPoint]
16
+
17
+
18
+ class _Template(NamedTuple):
19
+ # Arguments
20
+ package: _Param
21
+ # Options
22
+ verbose: _Param
23
+
24
+
25
+ template = _Template(
26
+ package=click.argument("package", default=None, required=False, type=str),
27
+ verbose=click.option("-v", "--verbose", is_flag=True),
28
+ )
29
+
30
+
31
+ @click.group()
32
+ def main() -> None:
33
+ pass
34
+
35
+
36
+ @main.command()
37
+ @template.verbose
38
+ def sync(verbose: bool) -> None:
39
+ """Vendor libraries as described in lockfile"""
40
+ UI.verbose = verbose
41
+ project_path = Path()
42
+
43
+ print(f"Working in {project_path}")
44
+
45
+ try:
46
+ with UI.task("Load configuration"):
47
+ config = load_configuration(project_path)
48
+ run_sync(config)
49
+ except VendoringError as e:
50
+ UI.show_error(e)
51
+ sys.exit(1)
52
+
53
+
54
+ @main.command()
55
+ @template.verbose
56
+ @template.package
57
+ def update(verbose: bool, package: Optional[str]) -> None:
58
+ """Update a single package version"""
59
+ UI.verbose = verbose
60
+ project_path = Path()
61
+
62
+ try:
63
+ with UI.task("Load configuration"):
64
+ config = load_configuration(project_path)
65
+ with UI.task("Updating requirements"):
66
+ update_requirements(config, package)
67
+ except VendoringError as e:
68
+ UI.show_error(e)
69
+ sys.exit(1)
70
+
71
+
72
+ @main.command("update-interactive")
73
+ @click.option("--skip", help="Skip named packages", multiple=True)
74
+ @click.option("--only", help="Only update named packages", multiple=True)
75
+ @click.option(
76
+ "--from-start",
77
+ help="Start from the beginning, ignoring existing markers",
78
+ is_flag=True,
79
+ )
80
+ @template.verbose
81
+ def update_interactive(
82
+ verbose: bool, skip: List[str], only: List[str], from_start: bool
83
+ ) -> None:
84
+ """Update all package versions, interactively"""
85
+ UI.verbose = verbose
86
+ project_path = Path()
87
+
88
+ try:
89
+ with UI.task("Load configuration"):
90
+ config = load_configuration(project_path)
91
+ interactive_updates(config, skip=skip, only=only, from_start=from_start)
92
+ except VendoringError as e:
93
+ UI.show_error(e)
94
+ sys.exit(1)
@@ -0,0 +1,178 @@
1
+ """Loads configuration from pyproject.toml"""
2
+
3
+ # mypy: allow-any-generics, allow-any-explicit
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import Any, Dict, List, Optional
8
+
9
+ from jsonschema import ValidationError, validate
10
+ from tomli import TOMLDecodeError
11
+ from tomli import loads as parse_toml
12
+
13
+ from vendoring.errors import ConfigurationError
14
+ from vendoring.ui import UI
15
+
16
+
17
+ @dataclass
18
+ class Configuration:
19
+ # Base directory for all of the operation of this project
20
+ base_directory: Path
21
+
22
+ # Location to unpack into
23
+ destination: Path
24
+ # Final namespace to rewrite imports to originate from
25
+ namespace: str
26
+ # Path to a pip-style requirement files
27
+ requirements: Path
28
+ # Filenames to ignore in target directory
29
+ protected_files: List[str]
30
+ # Location to ``.patch` files to apply after vendoring
31
+ patches_dir: Optional[Path]
32
+
33
+ # Additional substitutions, done in addition to import rewriting
34
+ substitute: List[Dict[str, str]]
35
+ # Drop
36
+ drop_paths: List[str]
37
+
38
+ # Fallbacks for licenses that can't be found
39
+ license_fallback_urls: Dict[str, str]
40
+ # Alternate directory name, when distribution name differs from the package name
41
+ license_directories: Dict[str, str]
42
+
43
+ # Overrides for which stub files are generated
44
+ typing_stubs: Dict[str, List[str]]
45
+
46
+ # SBOM file
47
+ sbom_file: Optional[Path]
48
+
49
+ @classmethod
50
+ def load_from_dict(
51
+ cls, dictionary: Dict[str, Any], *, location: Path
52
+ ) -> "Configuration":
53
+ """Constructs a Configuration object from dictionary.
54
+
55
+ It also performs validation of the values in `dictionary`,
56
+ expecting paths to be within `location`.
57
+ """
58
+
59
+ schema = {
60
+ "type": "object",
61
+ "additionalProperties": False,
62
+ "required": ["destination", "namespace", "requirements"],
63
+ "properties": {
64
+ "destination": {"type": "string"},
65
+ "namespace": {"type": "string"},
66
+ "requirements": {"type": "string"},
67
+ "protected-files": {"type": "array", "items": {"type": "string"}},
68
+ "patches-dir": {"type": "string"},
69
+ "sbom-file": {"type": "string"},
70
+ "transformations": {
71
+ "type": "object",
72
+ "additionalProperties": False,
73
+ "properties": {
74
+ "substitute": {
75
+ "type": "array",
76
+ "items": {
77
+ "type": "object",
78
+ "additionalProperties": False,
79
+ "required": ["match", "replace"],
80
+ "properties": {
81
+ "match": {"type": "string"},
82
+ "replace": {"type": "string"},
83
+ },
84
+ },
85
+ },
86
+ "drop": {"type": "array", "items": {"type": "string"}},
87
+ },
88
+ },
89
+ "license": {
90
+ "type": "object",
91
+ "additionalProperties": False,
92
+ "properties": {
93
+ "directories": {
94
+ "type": "object",
95
+ "patternProperties": {"^.*$": {"type": "string"}},
96
+ },
97
+ "fallback-urls": {
98
+ "type": "object",
99
+ "patternProperties": {"^.*$": {"type": "string"}},
100
+ },
101
+ },
102
+ },
103
+ "typing-stubs": {
104
+ "type": "object",
105
+ "patternProperties": {
106
+ "^.*$": {"type": "array", "items": {"type": "string"}},
107
+ },
108
+ },
109
+ },
110
+ }
111
+
112
+ try:
113
+ validate(dictionary, schema)
114
+ except ValidationError as e:
115
+ raise ConfigurationError(str(e))
116
+
117
+ def path_or_none(key: str) -> Optional[Path]:
118
+ if key in dictionary:
119
+ return Path(dictionary[key])
120
+ return None
121
+
122
+ return Configuration(
123
+ base_directory=location,
124
+ destination=Path(dictionary["destination"]),
125
+ namespace=dictionary["namespace"],
126
+ requirements=Path(dictionary["requirements"]),
127
+ protected_files=dictionary.get("protected-files", []),
128
+ patches_dir=path_or_none("patches-dir"),
129
+ sbom_file=path_or_none("sbom-file"),
130
+ substitute=dictionary.get("transformations", {}).get("substitute", {}),
131
+ drop_paths=dictionary.get("transformations", {}).get("drop", []),
132
+ license_fallback_urls=dictionary.get("license", {}).get(
133
+ "fallback-urls", {}
134
+ ),
135
+ license_directories=dictionary.get("license", {}).get("directories", {}),
136
+ typing_stubs=dictionary.get("typing-stubs", {}),
137
+ )
138
+
139
+
140
+ def load_configuration(directory: Path) -> Configuration:
141
+ # Read the contents of the file.
142
+ file = directory / "pyproject.toml"
143
+ UI.log(f"Will attempt to load {file}.")
144
+
145
+ try:
146
+ file_contents = file.read_text(encoding="utf8")
147
+ except IOError as read_error:
148
+ raise ConfigurationError("Could not read pyproject.toml.") from read_error
149
+ else:
150
+ UI.log("Read configuration file.")
151
+
152
+ try:
153
+ parsed_contents = parse_toml(file_contents)
154
+ except TOMLDecodeError as toml_error:
155
+ raise ConfigurationError("Could not parse pyproject.toml.") from toml_error
156
+ else:
157
+ UI.log("Parsed configuration file.")
158
+
159
+ if (
160
+ "tool" not in parsed_contents
161
+ or not isinstance(parsed_contents["tool"], dict)
162
+ or "vendoring" not in parsed_contents["tool"]
163
+ or not isinstance(parsed_contents["tool"]["vendoring"], dict)
164
+ ):
165
+ raise ConfigurationError("Can not load `tool.vendoring` from pyproject.toml")
166
+
167
+ tool_config = parsed_contents["tool"]["vendoring"]
168
+
169
+ try:
170
+ retval = Configuration.load_from_dict(tool_config, location=directory)
171
+ except ConfigurationError as e:
172
+ raise ConfigurationError(
173
+ "Could not load values from [tool.vendoring] in pyproject.toml.\n"
174
+ f" REASON: {e}"
175
+ )
176
+ else:
177
+ UI.log("Validated configuration.")
178
+ return retval
vendoring/errors.py ADDED
@@ -0,0 +1,10 @@
1
+ class VendoringError(Exception):
2
+ """Errors originating from this package."""
3
+
4
+
5
+ class ConfigurationError(VendoringError):
6
+ """Errors related to configuration handling."""
7
+
8
+
9
+ class RequirementsError(VendoringError):
10
+ """Errors related to requirements.txt handling."""
@@ -0,0 +1,197 @@
1
+ """Interactive mode, for updating all packages."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ from typing import Literal
7
+
8
+ import click as _click
9
+ from packaging.version import Version
10
+
11
+ from vendoring.configuration import Configuration
12
+ from vendoring.errors import VendoringError
13
+ from vendoring.sync import run_sync
14
+ from vendoring.tasks.update import (
15
+ PinnedPackageInfo,
16
+ determine_latest_release,
17
+ parse_pinned_packages,
18
+ )
19
+ from vendoring.ui import UI
20
+
21
+ Action = Literal["skip", "incomplete", "done"]
22
+
23
+
24
+ class InteractionState:
25
+ """Manages the state of the interactive session.
26
+
27
+ This handles the following:
28
+ - Reading the requirements file
29
+ - Writing the requirements file
30
+ - Reading the "state" file
31
+ - Keeping track of the current package, in memory and in the "state" file
32
+ - Writing the "state" file
33
+ - Cleaning up the "state" file
34
+ """
35
+
36
+ def __init__(self, config: Configuration) -> None:
37
+ self._requirements = config.requirements
38
+ self._state_file = (
39
+ config.base_directory
40
+ / ".vendoring_cache"
41
+ / "do-not-commit.interactive.current-package"
42
+ )
43
+
44
+ packages = parse_pinned_packages(self._requirements)
45
+ self._packages = {p.name: p for p in packages}
46
+ self._package_order = [p.name for p in packages]
47
+
48
+ self._current_package: str | None = None
49
+ if self._state_file.exists():
50
+ self._current_package = self._state_file.read_text()
51
+
52
+ @property
53
+ def packages(self) -> tuple[tuple[str, str], ...]:
54
+ """Return the list of packages."""
55
+ return tuple((p.name, p.version) for p in self._packages.values())
56
+
57
+ @property
58
+ def resuming_from(self) -> PinnedPackageInfo | None:
59
+ """Return the package we are resuming from."""
60
+ if self._current_package is None:
61
+ return None
62
+
63
+ if self._current_package not in self._packages:
64
+ raise VendoringError(
65
+ "The package to 'resume from' is not in requirements file\n"
66
+ f"package_name: {self._current_package}\n"
67
+ f"packages: {list(self._packages)}\n"
68
+ "Please run with `--from-start` to reset the state."
69
+ )
70
+ return self._packages[self._current_package]
71
+
72
+ def get_info(self, package: list[str]) -> list[PinnedPackageInfo]:
73
+ return [self._packages[p] for p in package]
74
+
75
+ def update(self, package_name: str, version: str) -> None:
76
+ """Update the state of the session."""
77
+ self._packages[package_name].version = version
78
+ with self._requirements.open("w", encoding="utf-8") as f:
79
+ f.writelines(f"{self._packages[p]}\n" for p in self._package_order)
80
+
81
+ self._current_package = package_name
82
+ if not self._state_file.parent.exists():
83
+ self._state_file.parent.mkdir()
84
+ self._state_file.write_text(package_name)
85
+
86
+ def cleanup(self) -> None:
87
+ if not self._state_file.exists():
88
+ return
89
+
90
+ self._state_file.unlink()
91
+ self._state_file.parent.rmdir()
92
+
93
+
94
+ def git(*args: str) -> None:
95
+ """Run a git command."""
96
+ cmd = ["git", *args]
97
+
98
+ UI.log(" ".join(map(str, cmd)))
99
+ subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL)
100
+
101
+
102
+ def do_one_update(config: Configuration, *, name: str, version: str) -> None:
103
+ """Perform the update of a single package."""
104
+ run_sync(config)
105
+
106
+ # Determine the correct message
107
+ message = f"Upgrade {name} to {version}"
108
+ # Write our news fragment
109
+ news_file = config.base_directory / "news" / (name + ".vendor.rst")
110
+ news_file.write_text(message + "\n") # "\n" appeases end-of-line-fixer
111
+
112
+ # Commit the changes
113
+ git("add", str(news_file))
114
+ git("commit", "-m", message)
115
+
116
+
117
+ def determine_packages(
118
+ packages: tuple[tuple[str, str], ...],
119
+ *,
120
+ resuming_from: PinnedPackageInfo | None,
121
+ skip: list[str],
122
+ only: list[str],
123
+ ) -> list[str]:
124
+ """Determine the packages to update."""
125
+ if resuming_from is not None:
126
+ if resuming_from.name in skip:
127
+ raise VendoringError(
128
+ f"Cannot resume from {resuming_from!r} as it is in the skip list"
129
+ )
130
+ if only and resuming_from.name not in only:
131
+ raise VendoringError(
132
+ f"Cannot resume from {resuming_from!r} as it is not in the only list"
133
+ )
134
+
135
+ if only:
136
+ known = {package[0] for package in packages}
137
+ for name in only:
138
+ if name not in known:
139
+ raise VendoringError(
140
+ f"Package {name!r} is not in the requirements file"
141
+ )
142
+ return only
143
+
144
+ if skip:
145
+ return [package[0] for package in packages if package[0] not in skip]
146
+
147
+ return [package[0] for package in packages]
148
+
149
+
150
+ def interactive_updates(
151
+ config: Configuration, *, skip: list[str], only: list[str], from_start: bool
152
+ ) -> None:
153
+ """Interactive mode, for updating all packages."""
154
+ with UI.task("Read incoming requirements"):
155
+ state = InteractionState(config)
156
+
157
+ if from_start:
158
+ state.cleanup()
159
+
160
+ resuming_from = state.resuming_from
161
+ package_names = determine_packages(
162
+ state.packages, resuming_from=resuming_from, skip=skip, only=only
163
+ )
164
+
165
+ def _present(package_info: PinnedPackageInfo, *, prefix: str | None = None) -> None:
166
+ real_prefix = f"{prefix} " if prefix else ""
167
+ UI.log(
168
+ f"{real_prefix}"
169
+ f"{_click.style(package_info.name, fg='green')} "
170
+ f"{_click.style('==', fg='blue')} "
171
+ f"{_click.style(package_info.version, fg='magenta')}"
172
+ )
173
+
174
+ if resuming_from is not None:
175
+ _present(resuming_from, prefix="Resuming from")
176
+ with UI.indent():
177
+ do_one_update(
178
+ config, name=resuming_from.name, version=resuming_from.version
179
+ )
180
+ UI.log(f"Processing remaining {len(package_names)} package(s)")
181
+ else:
182
+ UI.log(f"Processing {len(package_names)} package(s)")
183
+
184
+ with UI.indent():
185
+ for package_info in state.get_info(package_names):
186
+ _present(package_info)
187
+ with UI.indent():
188
+ latest_version = determine_latest_release(package_info.name)
189
+ if Version(latest_version) <= Version(package_info.version):
190
+ UI.log("Already up-to-date")
191
+ continue
192
+
193
+ state.update(package_info.name, latest_version)
194
+ do_one_update(config, name=package_info.name, version=latest_version)
195
+
196
+ UI.log("All done, removing marker file")
197
+ state.cleanup()
vendoring/sbom.py ADDED
@@ -0,0 +1,53 @@
1
+ """Code for generating a Software Bill-of-Materials (SBOM)
2
+ from vendored libraries.
3
+ """
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any, List
8
+ from urllib.parse import quote
9
+
10
+ from vendoring import __version__ as _vendoring_version
11
+ from vendoring.tasks.update import parse_pinned_packages as _parse_pinned_packages
12
+
13
+
14
+ def create_sbom_file(namespace: str, requirements: Path, sbom_file: Path) -> None:
15
+ # The top-most name in the module namespace is the
16
+ # most likely to be a recognizable name.
17
+ top_level = namespace.split(".", 1)[0]
18
+ top_level_bom_ref = f"bom-ref:{top_level}"
19
+ components: List[Any] = [
20
+ {"bom-ref": top_level_bom_ref, "name": top_level, "type": "library"}
21
+ ]
22
+ dependencies: List[Any] = [{"ref": top_level_bom_ref, "dependsOn": []}]
23
+ sbom = {
24
+ "$schema": "http://cyclonedx.org/schema/bom-1.4.schema.json",
25
+ "bomFormat": "CycloneDX",
26
+ "specVersion": "1.4",
27
+ "version": 1,
28
+ "metadata": {
29
+ "tools": [{"name": "vendoring", "version": _vendoring_version}],
30
+ "component": components[0],
31
+ },
32
+ "components": components,
33
+ "dependencies": dependencies,
34
+ }
35
+
36
+ pkgs = sorted(
37
+ _parse_pinned_packages(requirements), key=lambda item: (item.name, item.version)
38
+ )
39
+ for pkg in pkgs:
40
+ purl = f"pkg:pypi/{quote(pkg.name, safe='')}@{quote(pkg.version, safe='')}"
41
+ components.append(
42
+ {
43
+ "name": pkg.name,
44
+ "version": pkg.version,
45
+ "purl": purl,
46
+ "type": "library",
47
+ "bom-ref": purl,
48
+ }
49
+ )
50
+ dependencies[0]["dependsOn"].append(purl)
51
+ dependencies.append({"ref": purl})
52
+
53
+ sbom_file.write_text(json.dumps(sbom, indent=2, sort_keys=True))
vendoring/sync.py ADDED
@@ -0,0 +1,19 @@
1
+ """Core logic of the sync task."""
2
+
3
+ from vendoring.configuration import Configuration
4
+ from vendoring.tasks.cleanup import cleanup_existing_vendored
5
+ from vendoring.tasks.license import fetch_licenses
6
+ from vendoring.tasks.stubs import generate_stubs
7
+ from vendoring.tasks.vendor import vendor_libraries
8
+ from vendoring.ui import UI
9
+
10
+
11
+ def run_sync(config: Configuration) -> None:
12
+ with UI.task("Clean existing libraries"):
13
+ cleanup_existing_vendored(config)
14
+ with UI.task("Add vendored libraries"):
15
+ libraries = vendor_libraries(config)
16
+ with UI.task("Fetch licenses"):
17
+ fetch_licenses(config)
18
+ with UI.task("Generate static-typing stubs"):
19
+ generate_stubs(config, libraries)
File without changes
@@ -0,0 +1,32 @@
1
+ """Logic for cleaning up already vendored files."""
2
+
3
+ from pathlib import Path
4
+ from typing import Iterable, List
5
+
6
+ from vendoring.configuration import Configuration
7
+ from vendoring.utils import remove_all
8
+
9
+
10
+ def determine_items_to_remove(
11
+ destination: Path, *, files_to_skip: List[str]
12
+ ) -> Iterable[Path]:
13
+ if not destination.exists():
14
+ # Folder does not exist, nothing to cleanup.
15
+ return
16
+
17
+ for item in destination.iterdir():
18
+ if item.is_dir():
19
+ # Directory
20
+ yield item
21
+ elif item.name not in files_to_skip:
22
+ # File, not in files_to_skip
23
+ yield item
24
+
25
+
26
+ def cleanup_existing_vendored(config: Configuration) -> None:
27
+ """Cleans up existing vendored files in `destination` directory."""
28
+ destination = config.destination
29
+ items = determine_items_to_remove(destination, files_to_skip=config.protected_files)
30
+
31
+ # TODO: log how many items were removed.
32
+ remove_all(items, protected=[config.requirements])