From d47d246ca701cdc966d50d985c8b70fd4f2ceab4 Mon Sep 17 00:00:00 2001 From: Matthias Dellweg Date: Mon, 24 Aug 2026 17:43:35 +0200 Subject: [PATCH 1/2] Make preprocess_entity properly subclassable again (cherry picked from commit 930e766fc9109e42147599e7ae66bd9440f653ef) --- pulp-glue/src/pulp_glue/common/context.py | 39 +++++++++++++---------- pulp-glue/tests/test_entity_context.py | 11 +++++++ 2 files changed, 34 insertions(+), 16 deletions(-) create mode 100644 pulp-glue/tests/test_entity_context.py diff --git a/pulp-glue/src/pulp_glue/common/context.py b/pulp-glue/src/pulp_glue/common/context.py index a3b376a15..7bba844cb 100644 --- a/pulp-glue/src/pulp_glue/common/context.py +++ b/pulp-glue/src/pulp_glue/common/context.py @@ -70,7 +70,9 @@ def _inner(f: T) -> T: class PreprocessedEntityDefinition(dict[str, t.Any]): - pass + def __init__(self, /, *args: t.Any, _partial: bool, **kwargs: t.Any): + super().__init__(*args, **kwargs) + self._partial: bool = _partial EntityDefinition = dict[str, t.Any] | PreprocessedEntityDefinition @@ -132,7 +134,8 @@ def preprocess_payload(payload: EntityDefinition) -> EntityDefinition: return payload return PreprocessedEntityDefinition( - {key: _preprocess_value(value) for key, value in payload.items() if value is not None} + {key: _preprocess_value(value) for key, value in payload.items() if value is not None}, + _partial=False, ) @@ -957,6 +960,15 @@ def _preprocess_value(cls, key: str, value: t.Any) -> t.Any: return None return _preprocess_value(value) + def _preprocess_entity(self, body: EntityDefinition, partial: bool = False) -> EntityDefinition: + if isinstance(body, PreprocessedEntityDefinition): + assert body._partial == partial + return body + else: + return PreprocessedEntityDefinition( + self.preprocess_entity(body, partial), _partial=partial + ) + def preprocess_entity(self, body: EntityDefinition, partial: bool = False) -> EntityDefinition: """ Filter to prepare the body for a create or update call. @@ -971,16 +983,11 @@ def preprocess_entity(self, body: EntityDefinition, partial: bool = False) -> En Returns: The body ready to be passed to `call`. """ - if isinstance(body, PreprocessedEntityDefinition): - return body - - return PreprocessedEntityDefinition( - { - key: self._preprocess_value(key, value) - for key, value in body.items() - if value is not None - } - ) + return { + key: self._preprocess_value(key, value) + for key, value in body.items() + if value is not None + } def list_iterator( self, @@ -1123,7 +1130,7 @@ def create( if parameters: _parameters.update(parameters) if body is not None: - body = self.preprocess_entity(body, partial=False) + body = self._preprocess_entity(body, partial=False) if self.pulp_ctx.fake_mode: body["pulp_href"] = "" self._entity = body @@ -1184,7 +1191,7 @@ def update( if parameters: _parameters.update(parameters) if body is not None: - body = self.preprocess_entity(body, partial=True) + body = self._preprocess_entity(body, partial=True) if self.pulp_ctx.fake_mode: assert self._entity is not None if body is not None: @@ -1372,14 +1379,14 @@ def converge( return True, None, self.create(desired_entity) else: update_attributes = {} - for k, v in self.preprocess_entity(desired_attributes, partial=True).items(): + for k, v in self._preprocess_entity(desired_attributes, partial=True).items(): if entity.get(k) != v: update_attributes[k] = v if update_attributes: return ( True, entity, - self.update(PreprocessedEntityDefinition(update_attributes)), + self.update(PreprocessedEntityDefinition(update_attributes, _partial=True)), ) return False, entity, entity diff --git a/pulp-glue/tests/test_entity_context.py b/pulp-glue/tests/test_entity_context.py new file mode 100644 index 000000000..da211461b --- /dev/null +++ b/pulp-glue/tests/test_entity_context.py @@ -0,0 +1,11 @@ +from pulp_glue.common.context import PreprocessedEntityDefinition, PulpContext, PulpEntityContext + + +def test_preprocess_entity_is_only_called_once(mock_pulp_ctx: PulpContext) -> None: + entity_ctx = PulpEntityContext(mock_pulp_ctx) + + preprocessed = entity_ctx._preprocess_entity({}) + assert isinstance(preprocessed, PreprocessedEntityDefinition) + + # Now call it again and see if the returned object is the same, not just equal. + assert preprocessed is entity_ctx._preprocess_entity(preprocessed) From fd1c0e06fe034736fdd1496dea995556fcc992a8 Mon Sep 17 00:00:00 2001 From: Matthias Dellweg Date: Thu, 27 Aug 2026 12:08:47 +0200 Subject: [PATCH 2/2] Update from cookiecutter --- .ci/gen_certs.py | 24 +++-- .ci/run_container.sh | 4 + .ci/scripts/calc_constraints.py | 119 ------------------------- .ci/scripts/check_click_for_mypy.py | 4 +- .ci/scripts/collect_changes.py | 28 +++--- .ci/scripts/pr_labels.py | 2 +- .ci/scripts/validate_commit_message.py | 19 ++-- .github/workflows/build.yml | 21 ++--- .github/workflows/codeql.yml | 2 +- .github/workflows/collect_changes.yml | 17 ++-- .github/workflows/cookiecutter.yml | 8 +- .github/workflows/lint.yml | 22 ++--- .github/workflows/pr.yml | 25 +++--- .github/workflows/pr_checks.yml | 20 +++-- .github/workflows/publish.yml | 24 +++-- .github/workflows/release.yml | 13 +-- .github/workflows/release_branch.yml | 12 +-- .github/workflows/test.yml | 58 ++++++------ .gitignore | 7 +- Makefile | 69 ++++++++++---- docs/dev/guides/bootstrap.md | 19 ++-- docs/dev/learn/architecture.md | 9 +- lint_requirements.txt | 14 --- pulp-glue/pyproject.toml | 5 ++ pyproject.toml | 58 +++++++++++- test_requirements.txt | 10 --- 26 files changed, 285 insertions(+), 328 deletions(-) delete mode 100755 .ci/scripts/calc_constraints.py delete mode 100644 lint_requirements.txt delete mode 100644 test_requirements.txt diff --git a/.ci/gen_certs.py b/.ci/gen_certs.py index d55b0b912..e6eed981e 100644 --- a/.ci/gen_certs.py +++ b/.ci/gen_certs.py @@ -6,8 +6,8 @@ # /// import argparse -import os import sys +from pathlib import Path import trustme @@ -17,14 +17,14 @@ def main() -> None: parser.add_argument( "-d", "--dir", - default=os.getcwd(), + default=".", help="Directory where certificates and keys are written to. Defaults to cwd.", ) args = parser.parse_args(sys.argv[1:]) - cert_dir = args.dir + cert_dir = Path(args.dir) - if not os.path.isdir(cert_dir): + if not cert_dir.is_dir(): raise ValueError(f"--dir={cert_dir} is not a directory") key_type = trustme.KeyType["ECDSA"] @@ -32,28 +32,26 @@ def main() -> None: # Generate the CA certificate ca = trustme.CA(key_type=key_type) # Write the certificate the client should trust - ca_cert_path = os.path.join(cert_dir, "ca.pem") + ca_cert_path = cert_dir / "ca.pem" ca.cert_pem.write_to_path(path=ca_cert_path) # Generate the server certificate server_cert = ca.issue_cert("localhost", "127.0.0.1", "::1", key_type=key_type) # Write the certificate and private key the server should use - server_key_path = os.path.join(cert_dir, "server.key") - server_cert_path = os.path.join(cert_dir, "server.pem") + server_key_path = cert_dir / "server.key" + server_cert_path = cert_dir / "server.pem" server_cert.private_key_pem.write_to_path(path=server_key_path) - with open(server_cert_path, mode="w") as f: - f.truncate() + server_cert_path.write_text("") for blob in server_cert.cert_chain_pems: blob.write_to_path(path=server_cert_path, append=True) # Generate the client certificate client_cert = ca.issue_cert("admin@example.com", common_name="admin", key_type=key_type) # Write the certificate and private key the client should use - client_key_path = os.path.join(cert_dir, "client.key") - client_cert_path = os.path.join(cert_dir, "client.pem") + client_key_path = cert_dir / "client.key" + client_cert_path = cert_dir / "client.pem" client_cert.private_key_pem.write_to_path(path=client_key_path) - with open(client_cert_path, mode="w") as f: - f.truncate() + client_cert_path.write_text("") for blob in client_cert.cert_chain_pems: blob.write_to_path(path=client_cert_path, append=True) diff --git a/.ci/run_container.sh b/.ci/run_container.sh index b3a1c7e9b..d1a837cbe 100755 --- a/.ci/run_container.sh +++ b/.ci/run_container.sh @@ -69,6 +69,9 @@ else fi export PULP_CONTENT_ORIGIN +PULP_SECRET_KEY="$(python3 -c "import secrets; print(secrets.token_urlsafe(50))")" +export PULP_SECRET_KEY + "${CONTAINER_RUNTIME}" \ run ${RM:+--rm} \ --env S6_KEEP_ENV=1 \ @@ -79,6 +82,7 @@ export PULP_CONTENT_ORIGIN ${PULP_DOMAIN_ENABLED:+--env PULP_DOMAIN_ENABLED} \ ${PULP_ENABLED_PLUGINS:+--env PULP_ENABLED_PLUGINS} \ --env PULP_CONTENT_ORIGIN \ + --env PULP_SECRET_KEY \ --detach \ --name "pulp-ephemeral" \ --volume "${PULP_CLI_TEST_TMPDIR}/settings:/etc/pulp${SELINUX:+:Z}" \ diff --git a/.ci/scripts/calc_constraints.py b/.ci/scripts/calc_constraints.py deleted file mode 100755 index ca8e11e26..000000000 --- a/.ci/scripts/calc_constraints.py +++ /dev/null @@ -1,119 +0,0 @@ -#!/bin/python3 -# /// script -# requires-python = ">=3.10" -# dependencies = [ -# "packaging>=25.0,<25.1", -# "tomli>=2.3.0,<2.4.0;python_version<'3.11'", -# ] -# /// - -import argparse -import fileinput -import sys - -from packaging.requirements import Requirement -from packaging.version import Version - -try: - import tomllib -except ImportError: - import tomli as tomllib - - -def split_comment(line): - split_line = line.split("#", maxsplit=1) - try: - comment = " # " + split_line[1].strip() - except IndexError: - comment = "" - return split_line[0].strip(), comment - - -def to_upper_bound(req): - try: - requirement = Requirement(req) - except ValueError: - return f"# UNPARSABLE: {req}" - else: - for spec in requirement.specifier: - if spec.operator == "~=": - return f"# NO BETTER CONSTRAINT: {req}" - if spec.operator == "<=": - operator = "==" - max_version = spec.version - return f"{requirement.name}{operator}{max_version}" - if spec.operator == "<": - operator = "~=" - version = Version(spec.version) - if version.micro != 0: - max_version = f"{version.major}.{version.minor}.{version.micro - 1}" - elif version.minor != 0: - max_version = f"{version.major}.{version.minor - 1}" - elif version.major != 0: - max_version = f"{version.major - 1}.0" - else: - return f"# NO BETTER CONSTRAINT: {req}" - return f"{requirement.name}{operator}{max_version}" - return f"# NO UPPER BOUND: {req}" - - -def to_lower_bound(req): - try: - requirement = Requirement(req) - except ValueError: - return f"# UNPARSABLE: {req}" - else: - for spec in requirement.specifier: - if spec.operator == ">=": - if requirement.name == "pulpcore": - # Currently an exception to allow for pulpcore bugfix releases. - # TODO Semver libraries should be allowed too. - operator = "~=" - else: - operator = "==" - min_version = spec.version - return f"{requirement.name}{operator}{min_version}" - return f"# NO LOWER BOUND: {req}" - - -def main(): - """Calculate constraints for the lower bound of dependencies where possible.""" - parser = argparse.ArgumentParser( - prog=sys.argv[0], - description="Calculate constraints for the lower or upper bound of dependencies where " - "possible.", - ) - parser.add_argument("-u", "--upper", action="store_true") - parser.add_argument("filename", nargs="*") - args = parser.parse_args() - - modifier = to_upper_bound if args.upper else to_lower_bound - - req_files = [filename for filename in args.filename if not filename.endswith("pyproject.toml")] - pyp_files = [filename for filename in args.filename if filename.endswith("pyproject.toml")] - if req_files: - with fileinput.input(files=req_files) as req_file: - for line in req_file: - if line.strip().startswith("#"): - # Shortcut comment only lines - print(line.strip()) - else: - req, comment = split_comment(line) - new_req = modifier(req) - print(new_req + comment) - for filename in pyp_files: - with open(filename, "rb") as fp: - pyproject = tomllib.load(fp) - for req in pyproject["project"]["dependencies"]: - new_req = modifier(req) - print(new_req) - optional_dependencies = pyproject["project"].get("optional-dependencies") - if optional_dependencies: - for opt in optional_dependencies.values(): - for req in opt: - new_req = modifier(req) - print(new_req) - - -if __name__ == "__main__": - main() diff --git a/.ci/scripts/check_click_for_mypy.py b/.ci/scripts/check_click_for_mypy.py index 33ecf4ca1..d4b1aea7f 100755 --- a/.ci/scripts/check_click_for_mypy.py +++ b/.ci/scripts/check_click_for_mypy.py @@ -5,7 +5,7 @@ # "packaging>=25.0,<25.1", # ] # /// - +import sys from importlib import metadata from packaging.version import Version @@ -15,4 +15,4 @@ if click_version < Version("8.1.1"): print("🚧 Linting with mypy is currently only supported with click>=8.1.1. 🚧") print("🔧 Please run `pip install click>=8.1.1` first. 🔨") - exit(1) + sys.exit(1) diff --git a/.ci/scripts/collect_changes.py b/.ci/scripts/collect_changes.py index 499265cca..9ed2b481a 100755 --- a/.ci/scripts/collect_changes.py +++ b/.ci/scripts/collect_changes.py @@ -1,6 +1,6 @@ #!/bin/env python3 # /// script -# requires-python = ">=3.11" +# requires-python = ">=3.13" # dependencies = [ # "gitpython>=3.1.46,<3.2.0", # "packaging>=25.0,<25.1", @@ -8,15 +8,17 @@ # /// import itertools -import os import re +import typing as t +from pathlib import Path import tomllib from git import GitCommandError, Repo +from packaging.version import Version from packaging.version import parse as parse_version # Read Towncrier settings -with open("pyproject.toml", "rb") as fp: +with Path("pyproject.toml").open("rb") as fp: tc_settings = tomllib.load(fp)["tool"]["towncrier"] CHANGELOG_FILE = tc_settings.get("filename", "NEWS.rst") @@ -51,7 +53,7 @@ ) -def get_changelog(repo, branch): +def get_changelog(repo: Repo, branch: str) -> str: branch_tc_settings = tomllib.loads(repo.git.show(f"{branch}:pyproject.toml"))["tool"][ "towncrier" ] @@ -59,7 +61,7 @@ def get_changelog(repo, branch): return repo.git.show(f"{branch}:{branch_changelog_file}") + "\n" -def _tokenize_changes(splits): +def _tokenize_changes(splits: list[str]) -> t.Iterator[list[Version | str]]: assert len(splits) % 3 == 0 for i in range(len(splits) // 3): title = splits[3 * i] @@ -67,21 +69,20 @@ def _tokenize_changes(splits): yield [version, title + splits[3 * i + 2]] -def split_changelog(changelog): +def split_changelog(changelog: str) -> tuple[str, list[list[Version | str]]]: preamble, rest = changelog.split(START_STRING, maxsplit=1) split_rest = re.split(TITLE_REGEX, rest) return preamble + START_STRING + split_rest[0], list(_tokenize_changes(split_rest[1:])) -def main(): - repo = Repo(os.getcwd()) +def main() -> None: + repo = Repo(Path.cwd()) remote = repo.remotes[0] branches = [ref for ref in remote.refs if re.match(r"^([0-9]+)\.([0-9]+)$", ref.remote_head)] branches.sort(key=lambda ref: parse_version(ref.remote_head), reverse=True) branches = [ref.name for ref in branches] - with open(CHANGELOG_FILE, "r") as f: - main_changelog = f.read() + main_changelog = Path(CHANGELOG_FILE).read_text() preamble, main_changes = split_changelog(main_changelog) old_length = len(main_changes) @@ -92,7 +93,7 @@ def main(): except GitCommandError: print("No changelog found on this branch.") continue - dummy, changes = split_changelog(changelog) + _dummy, changes = split_changelog(changelog) new_changes = sorted(main_changes + changes, key=lambda x: x[0], reverse=True) # Now remove duplicates (retain the first one) main_changes = [new_changes[0]] @@ -103,10 +104,9 @@ def main(): new_length = len(main_changes) if old_length < new_length: print(f"{new_length - old_length} new versions have been added.") - with open(CHANGELOG_FILE, "w") as fp: + with Path(CHANGELOG_FILE).open("w") as fp: fp.write(preamble) - for change in main_changes: - fp.write(change[1]) + fp.writelines(change[1] for change in main_changes) repo.git.commit("-m", "Update Changelog", CHANGELOG_FILE) diff --git a/.ci/scripts/pr_labels.py b/.ci/scripts/pr_labels.py index 49eb4ada1..b2350b6d7 100755 --- a/.ci/scripts/pr_labels.py +++ b/.ci/scripts/pr_labels.py @@ -19,7 +19,7 @@ def main(): assert len(sys.argv) == 3 - with open("pyproject.toml", "rb") as fp: + with Path("pyproject.toml").open("rb") as fp: PYPROJECT_TOML = tomllib.load(fp) BLOCKING_REGEX = re.compile(r"DRAFT|WIP|NO\s*MERGE|DO\s*NOT\s*MERGE|EXPERIMENT") ISSUE_REGEX = re.compile(r"(?:fixes|closes)[\s:]+#(\d+)") diff --git a/.ci/scripts/validate_commit_message.py b/.ci/scripts/validate_commit_message.py index aeea5f29f..7e678c178 100644 --- a/.ci/scripts/validate_commit_message.py +++ b/.ci/scripts/validate_commit_message.py @@ -12,9 +12,8 @@ from pathlib import Path import tomllib -from github import Github -with open("pyproject.toml", "rb") as fp: +with Path("pyproject.toml").open("rb") as fp: PYPROJECT_TOML = tomllib.load(fp) KEYWORDS = ["fixes", "closes"] BLOCKING_REGEX = [ @@ -33,14 +32,16 @@ if NOISSUE_MARKER in message: sys.exit("Do not add '[noissue]' in the commit message.") -if any((re.match(pattern, message) for pattern in BLOCKING_REGEX)): +if any(re.match(pattern, message) for pattern in BLOCKING_REGEX): sys.exit("This PR is not ready for consumption.") -g = Github(os.environ.get("GITHUB_TOKEN")) -repo = g.get_repo("pulp/pulp-cli") +def check_status(issue: str) -> None: + from github import Github + + g = Github(os.environ.get("GITHUB_TOKEN")) + repo = g.get_repo("pulp/pulp-cli") -def check_status(issue): gi = repo.get_issue(int(issue)) if gi.pull_request: sys.exit(f"Error: issue #{issue} is a pull request.") @@ -48,7 +49,7 @@ def check_status(issue): sys.exit(f"Error: issue #{issue} is closed.") -def check_changelog(issue): +def check_changelog(issue: str) -> None: matches = list(Path("CHANGES").rglob(f"{issue}.*")) if len(matches) < 1: @@ -58,7 +59,7 @@ def check_changelog(issue): sys.exit(f"Invalid extension for changelog entry '{match}'.") -print("Checking commit message for {sha}.".format(sha=sha[0:7])) +print(f"Checking commit message for {sha[0:7]}.") # validate the issue attached to the commit issue_regex = r"(?:{keywords})[\s:]+#(\d+)".format(keywords=("|").join(KEYWORDS)) @@ -72,4 +73,4 @@ def check_changelog(issue): check_status(issue) check_changelog(issue) -print("Commit message for {sha} passed.".format(sha=sha[0:7])) +print(f"Commit message for {sha[0:7]} passed.") diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f0ddc0ba1..d7c6a4ba7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -8,30 +8,27 @@ jobs: build: runs-on: "ubuntu-latest" steps: - - uses: "actions/checkout@v6" - - uses: "actions/cache@v5" - with: - path: "~/.cache/pip" - key: "${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt', '**/*constraints.lock', '**/setup.py', '**/pyproject.toml') }}" - restore-keys: | - ${{ runner.os }}-pip- - + - name: "Checkout" + uses: "actions/checkout@v6" - name: "Set up Python" uses: "actions/setup-python@v6" with: python-version: "3.14" - - name: "Install python dependencies" - run: | - pip install build setuptools wheel + allow-prereleases: true + - name: "Install uv" + uses: "astral-sh/setup-uv@v7" + with: + enable-cache: true - name: "Build wheels" run: | make build + touch .root - name: "Upload wheels" uses: "actions/upload-artifact@v6" with: name: "pulp_cli_packages" path: | - pulp-glue/dist/ + .root dist/ if-no-files-found: "error" retention-days: 5 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 3c8da2543..facb1a8ff 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -17,7 +17,7 @@ jobs: security-events: "write" steps: - - name: "Checkout repository" + - name: "Checkout" uses: "actions/checkout@v6" - name: "Initialize CodeQL" uses: "github/codeql-action/init@v4" diff --git a/.github/workflows/collect_changes.yml b/.github/workflows/collect_changes.yml index f5ead777a..5e08242e6 100644 --- a/.github/workflows/collect_changes.yml +++ b/.github/workflows/collect_changes.yml @@ -8,21 +8,26 @@ jobs: collect-changes: runs-on: "ubuntu-latest" steps: - - uses: "actions/checkout@v6" + - name: "Checkout" + uses: "actions/checkout@v6" with: - ref: "main" fetch-depth: 0 - - uses: "actions/setup-python@v6" + - name: "Set up Python" + uses: "actions/setup-python@v6" with: - python-version: "3.x" + python-version: "3.14" + allow-prereleases: true + - name: "Install uv" + uses: "astral-sh/setup-uv@v7" + with: + enable-cache: true - name: "Setup git" run: | git config user.name pulpbot git config user.email pulp-infra@redhat.com - name: "Collect changes" run: | - pip install GitPython packaging - python3 .ci/scripts/collect_changes.py + uv run --script .ci/scripts/collect_changes.py - name: "Create Pull Request" uses: "peter-evans/create-pull-request@v8" id: "create_pr" diff --git a/.github/workflows/cookiecutter.yml b/.github/workflows/cookiecutter.yml index 30497dc5a..64dd8e1df 100644 --- a/.github/workflows/cookiecutter.yml +++ b/.github/workflows/cookiecutter.yml @@ -21,10 +21,6 @@ jobs: with: token: "${{ secrets.RELEASE_TOKEN }}" path: "pulp-cli" - - name: "Setup git" - run: | - git config user.name pulpbot - git config user.email pulp-infra@redhat.com - name: "Set up Python" uses: "actions/setup-python@v6" with: @@ -34,6 +30,10 @@ jobs: uses: "astral-sh/setup-uv@v7" with: enable-cache: true + - name: "Setup git" + run: | + git config user.name pulpbot + git config user.email pulp-infra@redhat.com - name: "Apply cookiecutter templates" run: | uv run ../pulp-cli/cookiecutter/apply_templates.py diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 61fbc5e98..fee95cdb1 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -14,25 +14,17 @@ jobs: - "3.11" - "3.14" steps: - - uses: "actions/checkout@v6" - - uses: "actions/cache@v5" - with: - path: "~/.cache/pip" - key: "${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt', '**/*constraints.lock', '**/setup.py', '**/pyproject.toml') }}" - restore-keys: | - ${{ runner.os }}-pip- - - - name: "Download wheels" - uses: "actions/download-artifact@v8" - with: - name: "pulp_cli_packages" + - name: "Checkout" + uses: "actions/checkout@v6" - name: "Set up Python" uses: "actions/setup-python@v6" with: python-version: "${{ matrix.python }}" - - name: "Install python dependencies" - run: | - pip install dist/pulp_cli-*.whl pulp-glue/dist/pulp_glue-*.whl -r lint_requirements.txt + allow-prereleases: true + - name: "Install uv" + uses: "astral-sh/setup-uv@v7" + with: + enable-cache: true - name: "Lint code" run: | make lint diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index e93cc09d3..742e2457f 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -9,15 +9,15 @@ concurrency: cancel-in-progress: true jobs: - build: - uses: "./.github/workflows/build.yml" lint: - needs: - - "build" uses: "./.github/workflows/lint.yml" - test: + build: needs: - "lint" + uses: "./.github/workflows/build.yml" + test: + needs: + - "build" uses: "./.github/workflows/test.yml" docs: needs: @@ -33,16 +33,19 @@ jobs: check-commits: runs-on: "ubuntu-latest" steps: - - uses: "actions/checkout@v6" + - name: "Checkout" + uses: "actions/checkout@v6" with: fetch-depth: 0 - name: "Set up Python" uses: "actions/setup-python@v6" with: - python-version: "3.x" - - name: "Install python dependencies" - run: | - pip install toml pygithub + python-version: "3.14" + allow-prereleases: true + - name: "Install uv" + uses: "astral-sh/setup-uv@v7" + with: + enable-cache: true - name: "Check commit message" env: GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" @@ -50,7 +53,7 @@ jobs: run: | for SHA in $(curl -H "Authorization: token $GITHUB_TOKEN" "$GITHUB_CONTEXT" | jq -r '.[].sha') do - python .ci/scripts/validate_commit_message.py "$SHA" + uv run -q .ci/scripts/validate_commit_message.py "$SHA" VALUE=$? if [ "$VALUE" -gt 0 ]; then exit "$VALUE" diff --git a/.github/workflows/pr_checks.yml b/.github/workflows/pr_checks.yml index 43f5c9467..c0317567f 100644 --- a/.github/workflows/pr_checks.yml +++ b/.github/workflows/pr_checks.yml @@ -19,19 +19,25 @@ jobs: permissions: pull-requests: "write" steps: - - uses: "actions/checkout@v6" + - name: "Checkout" + uses: "actions/checkout@v6" with: fetch-depth: 0 - - uses: "actions/setup-python@v6" + - name: "Set up Python" + uses: "actions/setup-python@v6" with: - python-version: "3.x" + python-version: "3.14" + allow-prereleases: true + - name: "Install uv" + uses: "astral-sh/setup-uv@v7" + with: + enable-cache: true - name: "Determine PR labels" run: | - pip install GitPython==3.1.42 git fetch origin ${{ github.event.pull_request.head.sha }} - python .ci/scripts/pr_labels.py "origin/${{ github.base_ref }}" "${{ github.event.pull_request.head.sha }}" >> "$GITHUB_ENV" - - uses: "actions/github-script@v8" - name: "Apply PR Labels" + uv run -q .ci/scripts/pr_labels.py "origin/${{ github.base_ref }}" "${{ github.event.pull_request.head.sha }}" >> "$GITHUB_ENV" + - name: "Apply PR Labels" + uses: "actions/github-script@v8" with: script: | const { ADD_LABELS, REMOVE_LABELS } = process.env; diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index eb21caadf..9256150cd 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -14,7 +14,8 @@ jobs: needs: "build" runs-on: "ubuntu-latest" steps: - - uses: "actions/checkout@v6" + - name: "Checkout" + uses: "actions/checkout@v6" - name: "Download wheels" uses: "actions/download-artifact@v8" with: @@ -22,18 +23,15 @@ jobs: - name: "Set up Python" uses: "actions/setup-python@v6" with: - python-version: "3.x" - - name: "Install dependencies" - run: | - python -m pip install --upgrade pip - pip install twine - - name: "Build and publish" + python-version: "3.14" + allow-prereleases: true + - name: "Install uv" + uses: "astral-sh/setup-uv@v7" + with: + enable-cache: true + - name: "Publish" env: - TWINE_USERNAME: "__token__" - TWINE_PASSWORD: "${{ secrets.PYPI_API_TOKEN }}" + UV_PUBLISH_TOKEN: "${{ secrets.PYPI_API_TOKEN }}" run: | - cd pulp-glue - twine upload dist/* - cd .. - twine upload dist/* + uv publish ... diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4cbd7459a..bda570851 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,16 +15,17 @@ jobs: - name: "Set up Python" uses: "actions/setup-python@v6" with: - python-version: "3.x" - - name: "Install dependencies" - run: | - python -m pip install --upgrade pip - pip install bump-my-version~=0.20.0 towncrier~=23.11.0 + python-version: "3.14" + allow-prereleases: true + - name: "Install uv" + uses: "astral-sh/setup-uv@v7" + with: + enable-cache: true - name: "Setup git" run: | git config user.name pulpbot git config user.email pulp-infra@redhat.com - name: "Release" run: | - .ci/scripts/release.sh + uv run --with bump-my-version~=0.20.0 --with towncrier~=23.11.0 .ci/scripts/release.sh ... diff --git a/.github/workflows/release_branch.yml b/.github/workflows/release_branch.yml index a75f0f325..f20a5ef3e 100644 --- a/.github/workflows/release_branch.yml +++ b/.github/workflows/release_branch.yml @@ -13,17 +13,19 @@ jobs: - name: "Set up Python" uses: "actions/setup-python@v6" with: - python-version: "3.x" + python-version: "3.14" + allow-prereleases: true + - name: "Install uv" + uses: "astral-sh/setup-uv@v7" + with: + enable-cache: true - name: "Setup git" run: | git config user.name pulpbot git config user.email pulp-infra@redhat.com - - name: "Install python dependencies" - run: | - pip install bump-my-version~=0.20.0 - name: "Create Release Branch" run: | - .ci/scripts/create_release_branch.sh + uv run --with bump-my-version~=0.20.0 .ci/scripts/create_release_branch.sh - name: "Create Pull Request" uses: "peter-evans/create-pull-request@v8" id: "create_pr" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0ee71bcc7..195ae485d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,14 +14,8 @@ jobs: unittest: runs-on: "ubuntu-latest" steps: - - uses: "actions/checkout@v6" - - uses: "actions/cache@v5" - with: - path: "~/.cache/pip" - key: "${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt', '**/*constraints.lock', '**/setup.py', '**/pyproject.toml') }}" - restore-keys: | - ${{ runner.os }}-pip- - + - name: "Checkout" + uses: "actions/checkout@v6" - name: "Download wheels" uses: "actions/download-artifact@v8" with: @@ -30,12 +24,14 @@ jobs: uses: "actions/setup-python@v6" with: python-version: "3.14" - - name: "Install Python Test Dependencies" - run: | - pip install dist/pulp_cli-*.whl pulp-glue/dist/pulp_glue-*.whl -r test_requirements.txt + allow-prereleases: true + - name: "Install uv" + uses: "astral-sh/setup-uv@v7" + with: + enable-cache: true - name: "Run tests" run: | - make unittest + uv run --isolated --with dist/pulp_glue*.whl --with dist/pulp_cli*.whl --only-group test make _unittest test: runs-on: "ubuntu-24.04" needs: @@ -73,14 +69,8 @@ jobs: lower_bounds: true python: "3.13" steps: - - uses: "actions/checkout@v6" - - uses: "actions/cache@v5" - with: - path: "~/.cache/pip" - key: "${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt', '**/*constraints.lock', '**/setup.py', '**/pyproject.toml') }}" - restore-keys: | - ${{ runner.os }}-pip- - + - name: "Checkout" + uses: "actions/checkout@v6" - name: "Download wheels" uses: "actions/download-artifact@v8" with: @@ -90,18 +80,10 @@ jobs: with: python-version: "${{ matrix.python }}" allow-prereleases: true - - name: "Install Python Test Dependencies" - run: | - if [ "${{matrix.lower_bounds}}" ] - then - pip install dist/pulp_cli-*.whl pulp-glue/dist/pulp_glue-*.whl -r test_requirements.txt -c lower_bounds_constraints.lock - elif [ "${{matrix.upper_bounds}}" ] - then - .ci/scripts/calc_constraints.py pyproject.toml pulp-glue/pyproject.toml --upper > upper_bounds_constraints.lock - pip install dist/pulp_cli-*.whl pulp-glue/dist/pulp_glue-*.whl -r test_requirements.txt -c upper_bounds_constraints.lock - else - pip install dist/pulp_cli-*.whl pulp-glue/dist/pulp_glue-*.whl -r test_requirements.txt - fi + - name: "Install uv" + uses: "astral-sh/setup-uv@v7" + with: + enable-cache: true - name: "Run tests" env: CONTAINER_RUNTIME: "${{ matrix.container_runtime }}" @@ -115,5 +97,15 @@ jobs: PULP_ENABLED_PLUGINS: "${{ matrix.pulp_enabled_plugins }}" OAS_VERSION: "${{ matrix.oas_version }}" run: | - .ci/run_container.sh make paralleltest + if [ "${{matrix.lower_bounds}}" ] + then + RESOLUTION=("--resolution" "lowest-direct") + elif [ "${{matrix.upper_bounds}}" ] + then + RESOLUTION=("--resolution" "highest") + else + RESOLUTION=() + fi + + uv run "${RESOLUTION[@]}" --isolated --with dist/pulp_glue*.whl --with dist/pulp_cli*.whl --only-group test .ci/run_container.sh make _paralleltest ... diff --git a/.gitignore b/.gitignore index ed761f6a8..4e13d3475 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,11 @@ *.egg-info __pycache__/ build/ -tests/cli.toml -GPG-PRIVATE-KEY-fixture-signing site/ dist/ *.po~ +uv.lock + +tests/cli.toml +GPG-PRIVATE-KEY-fixture-signing +.root diff --git a/Makefile b/Makefile index 917b36a07..8b190c671 100644 --- a/Makefile +++ b/Makefile @@ -12,55 +12,92 @@ info: .PHONY: build build: - cd pulp-glue; pyproject-build -n - pyproject-build -n + uv build --all + +.PHONY: _format +_format: + ruff format + ruff check --select I --fix .PHONY: format format: - ruff format + uv run --isolated --group lint $(MAKE) _format + +.PHONY: _autofix +_autofix: ruff check --fix -.PHONY: lint -lint: +.PHONY: autofix +autofix: + uv run --isolated --group lint $(MAKE) _autofix + +.PHONY: _lint +_lint: find tests .ci -name '*.sh' -print0 | xargs -0 shellcheck -x ruff format --check --diff - ruff check --diff + ruff check --output-format concise .ci/scripts/check_click_for_mypy.py mypy cd pulp-glue; mypy @echo "🙊 Code 🙈 LGTM 🙉 !" +.PHONY: lint +lint: + uv run --isolated --group lint $(MAKE) _lint + tests/cli.toml: cp $@.example $@ @echo "In order to configure the tests to talk to your test server, you might need to edit $@ ." +.PHONY: _test +_test: | tests/cli.toml + pytest -v tests pulp-glue/tests + .PHONY: test -test: | tests/cli.toml - python3 -m pytest -v tests pulp-glue/tests cookiecutter/pulp_filter_extension.py +test: + uv run $(MAKE) _test + +PYTEST_MARK ?= live + +.PHONY: _livetest +_livetest: | tests/cli.toml + pytest -v tests pulp-glue/tests -m "$(PYTEST_MARK)" .PHONY: livetest -livetest: | tests/cli.toml - python3 -m pytest -v tests pulp-glue/tests -m live +livetest: + uv run $(MAKE) _livetest + +.PHONY: _paralleltest +_paralleltest: | tests/cli.toml + pytest -v tests pulp-glue/tests -m "$(PYTEST_MARK)" -n 8 .PHONY: paralleltest -paralleltest: | tests/cli.toml - python3 -m pytest -v tests pulp-glue/tests -m live -n 8 +paralleltest: + uv run $(MAKE) _paralleltest + +.PHONY: _unittest +_unittest: + pytest -v tests pulp-glue/tests -m "not live" .PHONY: unittest unittest: - python3 -m pytest -v tests pulp-glue/tests cookiecutter/pulp_filter_extension.py -m "not live" + uv run $(MAKE) _unittest + +.PHONY: _unittest_glue +_unittest_glue: + pytest -v pulp-glue/tests -m "not live" .PHONY: unittest_glue unittest_glue: - python3 -m pytest -v pulp-glue/tests -m "not live" + uv run $(MAKE) _unittest_glue .PHONY: docs docs: - pulp-docs build + uv run --only-group docs pulp-docs build --draft --no-blog .PHONY: servedocs servedocs: - pulp-docs serve -w CHANGES.md -w pulp-glue/pulp_glue -w pulp_cli/generic.py + uv run --only-group docs pulp-docs serve --draft --no-blog -w CHANGES.md -w src -w pulp-glue/src pulp-glue/pulp_glue/%/locale/messages.pot: pulp-glue/pulp_glue/%/*.py xgettext -d $* -o $@ pulp-glue/pulp_glue/$*/*.py diff --git a/docs/dev/guides/bootstrap.md b/docs/dev/guides/bootstrap.md index e12574192..3f940b913 100644 --- a/docs/dev/guides/bootstrap.md +++ b/docs/dev/guides/bootstrap.md @@ -64,13 +64,10 @@ Edit `pulp-glue-my-plugin/pulp_glue/my_plugin/context.py` to define context clas ```python import typing as t -from pulp_glue.common.context import ( - PulpEntityContext, - PluginRequirement -) +from pulp_glue.common.context import PulpEntityContext, PluginRequirement -class PulpMyResourceContext(): +class PulpMyResourceContext: """Context for working with my custom resource.""" ID_PREFIX = "my_resource" @@ -79,12 +76,12 @@ class PulpMyResourceContext(): def example_action(self, data: t.Dict[str, t.Any]) -> t.Dict[str, t.Any]: """Execute an example action with specific data. - Args: - data: The data dictionary to send to the API + Args: + data: The data dictionary to send to the API - Returns: - The action result - """ + Returns: + The action result + """ response = self.call( operation="example_action", body=data, @@ -137,7 +134,7 @@ from pulp_cli.common.generic import pass_entity_context @click.group() @pass_pulp_context -@click.pass_context +@click.pass_context def my_resource(ctx: click.Context, pulp_ctx: PulpContext, /) -> None: """My custom commands.""" ctx.obj = PulpMyResourceContext(pulp_ctx) diff --git a/docs/dev/learn/architecture.md b/docs/dev/learn/architecture.md index 78ab29cb2..2877274ca 100644 --- a/docs/dev/learn/architecture.md +++ b/docs/dev/learn/architecture.md @@ -26,7 +26,7 @@ A plugin must register itself with the main app by specifying its main module as === "setup.py" ```python - entry_points={ + entry_points = { "pulp_cli.plugins": [ "myplugin=pulpcore.cli.myplugin", ], @@ -40,6 +40,7 @@ The plugin should then attach subcommands to the `pulpcore.cli.common.main` comm ```python from pulp_cli.generic import pulp_command + @pulp_command() def my_command(): pass @@ -139,7 +140,9 @@ class PulpMyResourceContext(PulpEntityContext): NEEDS_PLUGINS = [PluginRequirement("my_plugin", specifier=">=1.0.0")] def show(self) -> t.Dict[str, t.Any]: - if self.pulp_ctx.has_plugin(PluginRequirement("my_plugin", specifier=">=1.2.3", inverted=True)): + if self.pulp_ctx.has_plugin( + PluginRequirement("my_plugin", specifier=">=1.2.3", inverted=True) + ): # Versioned workaroud # see bug-tracker/12345678 return lookup_my_content_legacy(self.pulp_href) @@ -149,7 +152,7 @@ class PulpMyResourceContext(PulpEntityContext): # In pulp_cli_my_plugin @main.command() @pass_pulp_context -def my_command(pulp_ctx:PulpContext) -> None: +def my_command(pulp_ctx: PulpContext) -> None: pulp_ctx.needs_plugin(PluginRequirement("my_plugin", specifier=">=1.1.0")) # From here on we can assume `my_plugin>=1.1.0`. ``` diff --git a/lint_requirements.txt b/lint_requirements.txt deleted file mode 100644 index 4a5e7ce8e..000000000 --- a/lint_requirements.txt +++ /dev/null @@ -1,14 +0,0 @@ -# Lint requirements -ruff~=0.15.1 -mypy~=1.20.0 -shellcheck-py~=0.11.0.1 - -# Type annotation stubs -types-pygments -types-PyYAML -types-requests -types-setuptools -types-toml - -# Install the actual bits for mypy --r test_requirements.txt diff --git a/pulp-glue/pyproject.toml b/pulp-glue/pyproject.toml index 6e82ebe39..22fabe6d4 100644 --- a/pulp-glue/pyproject.toml +++ b/pulp-glue/pyproject.toml @@ -70,4 +70,9 @@ line-length = 100 [tool.ruff.lint] # This section is managed by the cookiecutter templates. extend-select = ["I"] +select = ["E4", "E7", "E9", "F"] + +[tool.ruff.lint.flake8-tidy-imports.banned-api] +# This section is managed by the cookiecutter templates. +"distutils".msg = "The 'distutils' module has been deprecated since Python 3.9." diff --git a/pyproject.toml b/pyproject.toml index 75567aaf5..24d8cc1bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -204,12 +204,18 @@ extend-exclude = ["cookiecutter"] [tool.ruff.lint] # This section is managed by the cookiecutter templates. extend-select = ["I"] +select = ["E4", "E7", "E9", "F"] [tool.ruff.lint.isort] # This section is managed by the cookiecutter templates. sections = { second-party = ["pulp_glue"] } section-order = ["future", "standard-library", "third-party", "second-party", "first-party", "local-folder"] +[tool.ruff.lint.flake8-tidy-imports.banned-api] +# This section is managed by the cookiecutter templates. +"distutils".msg = "The 'distutils' module has been deprecated since Python 3.9." +"pulpcore.cli.common.generic".msg = "This module moved to 'pulp_cli.generic'." + [tool.pytest] testpaths = ["tests", "pulp_glue/tests"] @@ -231,7 +237,7 @@ markers = [ strict = true warn_unused_ignores = false show_error_codes = true -files = "src/**/*.py, tests/*.py" +files = "src/**/*.py, tests/**/*.py" mypy_path = ["src", "pulp-glue/src"] namespace_packages = true explicit_package_bases = true @@ -246,3 +252,53 @@ module = [ "schema.*", ] ignore_missing_imports = true + + +[tool.uv.sources] +# This section is managed by the cookiecutter templates. +pulp-glue = { workspace = true } +pulp-docs = { git = "https://github.com/pulp/pulp-docs" } + +[tool.uv.workspace] +# This section is managed by the cookiecutter templates. +members = ["pulp-glue"] + +[tool.uv.dependency-groups] +# This section is managed by the cookiecutter templates. +docs = {requires-python = ">=3.11"} + +[tool.uv.build-backend] +# This section is managed by the cookiecutter templates. +module-name = ["pulpcore.cli", "pulp_cli", "pytest_pulp_cli"] +namespace = true +source-exclude = ["*.pot", "*.po", "**/*\\~"] + + +[dependency-groups] +dev = [ + {include-group = "lint"}, + "pylsp-mypy>=0.7.0", + "pylsp-rope>=0.1.17,<0.1.18", +] +lint = [ + {include-group = "test"}, + "mypy~=1.20.0", + "ruff~=0.16.0", + "shellcheck-py~=0.11.0.1", + "types-pygments", + "types-pyyaml", + "types-requests", + "types-setuptools", + "types-toml", +] +test = [ + "pygments>=2.19.2", + "pytest>=7.0.0,<9.2", + "pytest-xdist>=3.8.0,<3.9", + "python-gnupg>=0.5.0,<0.6", + "secretstorage>=3.5.0", + "trustme>=1.1.0,<1.3", +] +docs = [ + "pulp-docs", +] diff --git a/test_requirements.txt b/test_requirements.txt deleted file mode 100644 index 710bc4f2d..000000000 --- a/test_requirements.txt +++ /dev/null @@ -1,10 +0,0 @@ -# Test requirements -pytest>=7.0.0,<9.1 -pytest-xdist -python-gnupg>=0.5.0,<0.6 -trustme>=1.1.0,<1.3 -jinja2>=3.1.4,<3.2 - -# No pinning here, because we only switch on optional dependencies here. -pygments -SecretStorage