From 7f37f3e56710d12ff277ff5301b93702c7d2bf27 Mon Sep 17 00:00:00 2001 From: bertiethorpe Date: Tue, 17 Jun 2025 16:17:06 +0100 Subject: [PATCH 1/5] add tox config --- .stestr.conf | 3 ++ pyhelm3/tests/__init__.py | 0 pyhelm3/tests/test_placeholder.py | 6 ++++ pyproject.toml | 44 ++++++++++++++++++++++++ requirements.txt | 2 ++ test-requirements.txt | 11 ++++++ tox.ini | 56 +++++++++++++++++++++++++++++++ 7 files changed, 122 insertions(+) create mode 100644 .stestr.conf create mode 100644 pyhelm3/tests/__init__.py create mode 100644 pyhelm3/tests/test_placeholder.py create mode 100644 requirements.txt create mode 100644 test-requirements.txt create mode 100644 tox.ini diff --git a/.stestr.conf b/.stestr.conf new file mode 100644 index 0000000..58d44fb --- /dev/null +++ b/.stestr.conf @@ -0,0 +1,3 @@ +[DEFAULT] +test_path=./pyhelm3/tests +top_dir=./ \ No newline at end of file diff --git a/pyhelm3/tests/__init__.py b/pyhelm3/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pyhelm3/tests/test_placeholder.py b/pyhelm3/tests/test_placeholder.py new file mode 100644 index 0000000..8bb8293 --- /dev/null +++ b/pyhelm3/tests/test_placeholder.py @@ -0,0 +1,6 @@ +import unittest + + +class PlaceholderTest(unittest.TestCase): + def test_placeholder(self): + self.assertTrue(True) diff --git a/pyproject.toml b/pyproject.toml index 1cd983f..9abf0f1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,3 +3,47 @@ requires = ["setuptools>=42", "wheel", "setuptools_scm[toml]>=3.4"] build-backend = "setuptools.build_meta" [tool.setuptools_scm] + +[tool.ruff] +line-length = 88 +target-version = "py310" + +[tool.ruff.format] +line-ending = "lf" + +[tool.ruff.lint] +select = [ + # pycodestyle + "E", + # Pyflakes + "F", + # flake8-builtins + "A", + # Ruff rules + "RUF", + # flake8-async + "ASYNC", + # pyupgrade + "UP", + # tidy imports + "TID", + # sorted imports + "I", + # check complexity + "C90", + # pep8 naming + "N", +] +ignore = [ + "UP038", # deprecated +] + +[tool.mypy] +follow_imports = "silent" +warn_redundant_casts = true +warn_unused_ignores = true +check_untyped_defs = true + +[tool.ruff.lint.mccabe] +# Flag errors (`C901`) whenever the complexity level exceeds 5. +max-complexity = 15 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..abc79b5 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +pydantic==2.11.5 +PyYAML==6.0.2 \ No newline at end of file diff --git a/test-requirements.txt b/test-requirements.txt new file mode 100644 index 0000000..f28ca05 --- /dev/null +++ b/test-requirements.txt @@ -0,0 +1,11 @@ +ruff~=0.11.8 +coverage>=4.0,!=4.4 # Apache-2.0 +python-subunit>=0.0.18 # Apache-2.0/BSD +stestr>=1.0.0 # Apache-2.0 +testtools>=1.4.0 # MIT +codespell +autopep8 +mypy +types-PyYAML +types-setuptools +black~=25.1.0 \ No newline at end of file diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..dcbcf16 --- /dev/null +++ b/tox.ini @@ -0,0 +1,56 @@ +[tox] +minversion = 4.0.0 +# We run autofix last, to ensure CI fails, +# even though we do our best to autofix locally +envlist = py3,ruff,codespell, autofix +# TODO: fix mypy and add to envlist +skipsdist = True + +[testenv] +basepython = python3 +usedevelop = True +setenv = + PYTHONWARNINGS=default::DeprecationWarning + OS_STDOUT_CAPTURE=1 + OS_STDERR_CAPTURE=1 + OS_TEST_TIMEOUT=60 +deps = -r{toxinidir}/requirements.txt + -r{toxinidir}/test-requirements.txt +commands = stestr run {posargs} + +[testenv:autofix] +commands = + ruff format {tox_root} + codespell {tox_root} -w + ruff check {tox_root} --fix + +[testenv:black] +# TODO: understand why ruff doesn't fix +# line lengths as well as black does +commands = black {tox_root} {posargs} + +[testenv:codespell] +commands = codespell {posargs} + +[testenv:ruff] +description = Run Ruff checks +commands = + ruff check {tox_root} {posargs} + ruff format {tox_root} --check + +[testenv:venv] +commands = {posargs} + +[testenv:cover] +setenv = + VIRTUAL_ENV={envdir} + PYTHON=coverage run --source pyhelm3 --parallel-mode +commands = + stestr run {posargs} + coverage combine + coverage html -d cover + coverage xml -o cover/coverage.xml + coverage report + +[testenv:mypy] +commands = mypy {tox_root} {posargs} \ No newline at end of file From 61d9b98e5edf1b987cac1d94ea79c92461d756b1 Mon Sep 17 00:00:00 2001 From: bertiethorpe Date: Tue, 17 Jun 2025 16:18:03 +0100 Subject: [PATCH 2/5] fix up tox linting --- pyhelm3/__init__.py | 8 +- pyhelm3/client.py | 290 +++++++++++++------------ pyhelm3/command.py | 307 ++++++++++++++------------- pyhelm3/errors.py | 3 +- pyhelm3/models.py | 500 ++++++++++++++++++++------------------------ 5 files changed, 537 insertions(+), 571 deletions(-) diff --git a/pyhelm3/__init__.py b/pyhelm3/__init__.py index 45bfb86..60869a1 100644 --- a/pyhelm3/__init__.py +++ b/pyhelm3/__init__.py @@ -1,4 +1,4 @@ -from .client import * -from .command import * -from .errors import * -from .models import * +from .client import * # noqa: F403 +from .command import * # noqa: F403 +from .errors import * # noqa: F403 +from .models import * # noqa: F403 diff --git a/pyhelm3/client.py b/pyhelm3/client.py index 4fec72b..3be32b2 100644 --- a/pyhelm3/client.py +++ b/pyhelm3/client.py @@ -12,12 +12,12 @@ def mergeconcat( - defaults: t.Dict[t.Any, t.Any], - *overrides: t.Dict[t.Any, t.Any] -) -> t.Dict[t.Any, t.Any]: + defaults: dict[t.Any, t.Any], *overrides: dict[t.Any, t.Any] +) -> dict[t.Any, t.Any]: """ Deep-merge two or more dictionaries together. Lists are concatenated. """ + def mergeconcat2(defaults, overrides): if isinstance(defaults, dict) and isinstance(overrides, dict): merged = dict(defaults) @@ -27,81 +27,82 @@ def mergeconcat2(defaults, overrides): else: merged[key] = value return merged - elif isinstance(defaults, (list, tuple)) and isinstance(overrides, (list, tuple)): + elif isinstance(defaults, (list, tuple)) and isinstance( + overrides, (list, tuple) + ): merged = list(defaults) merged.extend(overrides) return merged else: return overrides if overrides is not None else defaults + return functools.reduce(mergeconcat2, overrides, defaults) #: Bound type var for forward references -ClientType = t.TypeVar("ClientType", bound = "Client") +ClientType = t.TypeVar("ClientType", bound="Client") class Client: """ Entrypoint for interactions with Helm. """ + def __init__( self, - command: t.Optional[Command] = None, + command: Command | None = None, *, - default_timeout: t.Union[int, str] = "5m", + default_timeout: int | str = "5m", executable: str = "helm", history_max_revisions: int = 10, insecure_skip_tls_verify: bool = False, - kubeconfig: t.Optional[pathlib.Path] = None, - kubecontext: t.Optional[str] = None, - kubeapiserver: t.Optional[str] = None, - kubetoken: t.Optional[str] = None, - unpack_directory: t.Optional[str] = None + kubeconfig: pathlib.Path | None = None, + kubecontext: str | None = None, + kubeapiserver: str | None = None, + kubetoken: str | None = None, + unpack_directory: str | None = None, ): self._command = command or Command( - default_timeout = default_timeout, - executable = executable, - history_max_revisions = history_max_revisions, - insecure_skip_tls_verify = insecure_skip_tls_verify, - kubeconfig = kubeconfig, - kubecontext = kubecontext, - kubeapiserver = kubeapiserver, - kubetoken = kubetoken, - unpack_directory = unpack_directory + default_timeout=default_timeout, + executable=executable, + history_max_revisions=history_max_revisions, + insecure_skip_tls_verify=insecure_skip_tls_verify, + kubeconfig=kubeconfig, + kubecontext=kubecontext, + kubeapiserver=kubeapiserver, + kubetoken=kubetoken, + unpack_directory=unpack_directory, ) async def get_chart( self, - chart_ref: t.Union[pathlib.Path, str], + chart_ref: pathlib.Path | str, *, devel: bool = False, - repo: t.Optional[str] = None, - version: t.Optional[str] = None + repo: str | None = None, + version: str | None = None, ) -> Chart: """ Returns the resolved chart for the given ref, repo and version. """ return Chart( self._command, - ref = chart_ref, - repo = repo, + ref=chart_ref, + repo=repo, # Load the metadata for the specified args - metadata = await self._command.show_chart( - chart_ref, - devel = devel, - repo = repo, - version = version - ) + metadata=await self._command.show_chart( + chart_ref, devel=devel, repo=repo, version=version + ), ) @contextlib.asynccontextmanager async def pull_chart( self, - chart_ref: t.Union[pathlib.Path, str], + chart_ref: pathlib.Path | str, *, devel: bool = False, - repo: t.Optional[str] = None, - version: t.Optional[str] = None + repo: str | None = None, + version: str | None = None, ) -> t.AsyncIterator[pathlib.Path]: """ Context manager that pulls the specified chart and yields a chart object @@ -110,21 +111,20 @@ async def pull_chart( Ensures that the directory is cleaned up when the context manager exits. """ path = await self._command.pull( - chart_ref, - devel = devel, - repo = repo, - version = version + chart_ref, devel=devel, repo=repo, version=version ) try: - # The path from pull is the managed directory containing the archive and unpacked chart + # The path from pull is the managed directory containing the archive and + # unpacked chart # We want the actual chart directory chart_yaml = next(path.glob("**/Chart.yaml")) chart_directory = chart_yaml.parent - # To save the overhead of another Helm command invocation, just read the Chart.yaml + # To save the overhead of another Helm command invocation, + # just read the Chart.yaml with chart_yaml.open() as fh: - metadata = yaml.load(fh, Loader = SafeLoader) + metadata = yaml.load(fh, Loader=SafeLoader) # Yield the chart object - yield Chart(self._command, ref = chart_directory, metadata = metadata) + yield Chart(self._command, ref=chart_directory, metadata=metadata) finally: if path.is_dir(): shutil.rmtree(path) @@ -133,12 +133,12 @@ async def template_resources( self, chart: Chart, release_name: str, - *values: t.Dict[str, t.Any], + *values: dict[str, t.Any], include_crds: bool = False, is_upgrade: bool = False, - namespace: t.Optional[str] = None, + namespace: str | None = None, no_hooks: bool = False, - ) -> t.Iterable[t.Dict[str, t.Any]]: + ) -> t.Iterable[dict[str, t.Any]]: """ Renders the templates from the given chart with the given values and returns the resources that would be produced. @@ -147,18 +147,18 @@ async def template_resources( release_name, chart.ref, mergeconcat(*values) if values else None, - include_crds = include_crds, - is_upgrade = is_upgrade, - namespace = namespace, - no_hooks = no_hooks, - repo = chart.repo, - version = chart.metadata.version + include_crds=include_crds, + is_upgrade=is_upgrade, + namespace=namespace, + no_hooks=no_hooks, + repo=chart.repo, + version=chart.metadata.version, ) async def list_releases( self, *, - all: bool = False, + all: bool = False, # noqa: A002 all_namespaces: bool = False, include_deployed: bool = True, include_failed: bool = False, @@ -167,9 +167,9 @@ async def list_releases( include_uninstalled: bool = False, include_uninstalling: bool = False, max_releases: int = 256, - namespace: t.Optional[str] = None, + namespace: str | None = None, sort_by_date: bool = False, - sort_reversed: bool = False + sort_reversed: bool = False, ) -> t.Iterable[Release]: """ Returns an iterable of the deployed releases. @@ -177,60 +177,53 @@ async def list_releases( return ( Release( self._command, - name = release["name"], - namespace = release["namespace"], + name=release["name"], + namespace=release["namespace"], ) for release in await self._command.list( - all = all, - all_namespaces = all_namespaces, - include_deployed = include_deployed, - include_failed = include_failed, - include_pending = include_pending, - include_superseded = include_superseded, - include_uninstalled = include_uninstalled, - include_uninstalling = include_uninstalling, - max_releases = max_releases, - namespace = namespace, - sort_by_date = sort_by_date, - sort_reversed = sort_reversed + all=all, + all_namespaces=all_namespaces, + include_deployed=include_deployed, + include_failed=include_failed, + include_pending=include_pending, + include_superseded=include_superseded, + include_uninstalled=include_uninstalled, + include_uninstalling=include_uninstalling, + max_releases=max_releases, + namespace=namespace, + sort_by_date=sort_by_date, + sort_reversed=sort_reversed, ) ) async def get_current_revision( - self, - release_name: str, - *, - namespace: t.Optional[str] = None + self, release_name: str, *, namespace: str | None = None ) -> ReleaseRevision: """ Returns the current revision of the named release. """ return ReleaseRevision._from_status( - await self._command.status( - release_name, - namespace = namespace - ), - self._command + await self._command.status(release_name, namespace=namespace), self._command ) async def install_or_upgrade_release( self, release_name: str, chart: Chart, - *values: t.Dict[str, t.Any], + *values: dict[str, t.Any], atomic: bool = False, cleanup_on_fail: bool = False, create_namespace: bool = True, - description: t.Optional[str] = None, + description: str | None = None, dry_run: bool = False, force: bool = False, - namespace: t.Optional[str] = None, + namespace: str | None = None, no_hooks: bool = False, reset_values: bool = False, reuse_values: bool = False, skip_crds: bool = False, - timeout: t.Union[int, str, None] = None, - wait: bool = False + timeout: int | str | None = None, + wait: bool = False, ) -> ReleaseRevision: """ Install or upgrade the named release using the given chart and values and return @@ -241,31 +234,31 @@ async def install_or_upgrade_release( release_name, chart.ref, mergeconcat(*values) if values else None, - atomic = atomic, - cleanup_on_fail = cleanup_on_fail, - create_namespace = create_namespace, - description = description, - dry_run = dry_run, - force = force, - namespace = namespace, - no_hooks = no_hooks, - repo = chart.repo, - reset_values = reset_values, - reuse_values = reuse_values, - skip_crds = skip_crds, - timeout = timeout, - version = chart.metadata.version, - wait = wait + atomic=atomic, + cleanup_on_fail=cleanup_on_fail, + create_namespace=create_namespace, + description=description, + dry_run=dry_run, + force=force, + namespace=namespace, + no_hooks=no_hooks, + repo=chart.repo, + reset_values=reset_values, + reuse_values=reuse_values, + skip_crds=skip_crds, + timeout=timeout, + version=chart.metadata.version, + wait=wait, ), - self._command + self._command, ) async def get_proceedable_revision( self, release_name: str, *, - namespace: t.Optional[str] = None, - timeout: t.Union[int, str, None] = None + namespace: str | None = None, + timeout: int | str | None = None, ) -> ReleaseRevision: """ Returns a proceedable revision for the named release by rolling back or deleting @@ -273,33 +266,34 @@ async def get_proceedable_revision( """ try: current_revision = await self.get_current_revision( - release_name, - namespace = namespace + release_name, namespace=namespace ) except ReleaseNotFoundError: # This condition is an easy one ;-) return None else: if current_revision.status in { - # If the release is stuck in pending-install, there is nothing to rollback to + # If the release is stuck in pending-install, + # there is nothing to rollback to # Instead, we have to uninstall the release and try again ReleaseRevisionStatus.PENDING_INSTALL, - # If the release is stuck in uninstalling, we need to complete the uninstall + # If the release is stuck in uninstalling, + # we need to complete the uninstall ReleaseRevisionStatus.UNINSTALLING, }: - await current_revision.release.uninstall(timeout = timeout, wait = True) + await current_revision.release.uninstall(timeout=timeout, wait=True) return None elif current_revision.status in { - # If the release is stuck in pending-upgrade, we need to rollback to the previous + # If the release is stuck in pending-upgrade, + # we need to rollback to the previous # revision before trying the upgrade again ReleaseRevisionStatus.PENDING_UPGRADE, - # For a release stuck in pending-rollback, we need to complete the rollback + # For a release stuck in pending-rollback, + # we need to complete the rollback ReleaseRevisionStatus.PENDING_ROLLBACK, }: return await current_revision.release.rollback( - cleanup_on_fail = True, - timeout = timeout, - wait = True + cleanup_on_fail=True, timeout=timeout, wait=True ) else: # All other statuses are proceedable @@ -307,9 +301,9 @@ async def get_proceedable_revision( async def should_install_or_upgrade_release( self, - current_revision: t.Optional[ReleaseRevision], + current_revision: ReleaseRevision | None, chart: Chart, - *values: t.Dict[str, t.Any] + *values: dict[str, t.Any], ) -> bool: """ Returns True if an install or upgrade is required based on the given revision, @@ -340,56 +334,52 @@ async def ensure_release( self, release_name: str, chart: Chart, - *values: t.Dict[str, t.Any], + *values: dict[str, t.Any], atomic: bool = False, cleanup_on_fail: bool = False, create_namespace: bool = True, - description: t.Optional[str] = None, + description: str | None = None, force: bool = False, - namespace: t.Optional[str] = None, + namespace: str | None = None, no_hooks: bool = False, reset_values: bool = False, reuse_values: bool = False, skip_crds: bool = False, - timeout: t.Union[int, str, None] = None, - wait: bool = False + timeout: int | str | None = None, + wait: bool = False, ) -> ReleaseRevision: """ - Ensures the named release matches the given chart and values and return the current - revision. + Ensures the named release matches the given chart and values and return the + current revision. - It the release must be rolled back or deleted in order to be proceedable, this method - will ensure that happens. It will also only make a new release if the chart and/or - values have changed. + It the release must be rolled back or deleted in order to be proceedable, + this method will ensure that happens. It will also only make a new release + if the chart and/or values have changed. """ values = mergeconcat(*values) if values else {} current_revision = await self.get_proceedable_revision( - release_name, - namespace = namespace, - timeout = timeout + release_name, namespace=namespace, timeout=timeout ) should_install_or_upgrade = await self.should_install_or_upgrade_release( - current_revision, - chart, - values + current_revision, chart, values ) if should_install_or_upgrade: return await self.install_or_upgrade_release( release_name, chart, values, - atomic = atomic, - cleanup_on_fail = cleanup_on_fail, - create_namespace = create_namespace, - description = description, - force = force, - namespace = namespace, - no_hooks = no_hooks, - reset_values = reset_values, - reuse_values = reuse_values, - skip_crds = skip_crds, - timeout = timeout, - wait = wait + atomic=atomic, + cleanup_on_fail=cleanup_on_fail, + create_namespace=create_namespace, + description=description, + force=force, + namespace=namespace, + no_hooks=no_hooks, + reset_values=reset_values, + reuse_values=reuse_values, + skip_crds=skip_crds, + timeout=timeout, + wait=wait, ) else: return current_revision @@ -400,10 +390,10 @@ async def uninstall_release( *, dry_run: bool = False, keep_history: bool = False, - namespace: t.Optional[str] = None, + namespace: str | None = None, no_hooks: bool = False, - timeout: t.Union[int, str, None] = None, - wait: bool = False + timeout: int | str | None = None, + wait: bool = False, ): """ Uninstall the named release. @@ -411,12 +401,12 @@ async def uninstall_release( try: await self._command.uninstall( release_name, - dry_run = dry_run, - keep_history = keep_history, - namespace = namespace, - no_hooks = no_hooks, - timeout = timeout, - wait = wait + dry_run=dry_run, + keep_history=keep_history, + namespace=namespace, + no_hooks=no_hooks, + timeout=timeout, + wait=wait, ) except ReleaseNotFoundError: # If the release does not exist, it is deleted :-) diff --git a/pyhelm3/command.py b/pyhelm3/command.py index 1b25c2d..9b98310 100644 --- a/pyhelm3/command.py +++ b/pyhelm3/command.py @@ -16,16 +16,18 @@ class SafeLoader(yaml.SafeLoader): """ We use a custom YAML loader that doesn't bork on plain equals '=' signs. - - It was originally designated with a special meaning, but noone uses it: + + It was originally designated with a special meaning, but no one uses it: https://github.com/yaml/pyyaml/issues/89 https://yaml.org/type/value.html """ + @staticmethod def construct_value(loader, node): return loader.construct_scalar(node) + SafeLoader.add_constructor("tag:yaml.org,2002:value", SafeLoader.construct_value) @@ -125,29 +127,32 @@ def construct_value(loader, node): #: Bound type var for forward references -CommandType = t.TypeVar("CommandType", bound = "Command") +CommandType = t.TypeVar("CommandType", bound="Command") CHART_NOT_FOUND = re.compile(r"chart \"[^\"]+\" (version \"[^\"]+\" )?not found") -CONNECTION_ERROR = re.compile(r"(read: operation timed out|connect: network is unreachable)") +CONNECTION_ERROR = re.compile( + r"(read: operation timed out|connect: network is unreachable)" +) class Command: """ Class presenting an async interface around the Helm CLI. """ + def __init__( self, *, - default_timeout: t.Union[int, str] = "5m", + default_timeout: int | str = "5m", executable: str = "helm", history_max_revisions: int = 10, insecure_skip_tls_verify: bool = False, - kubeconfig: t.Optional[pathlib.Path] = None, - kubecontext: t.Optional[str] = None, - kubeapiserver: t.Optional[str] = None, - kubetoken: t.Optional[str] = None, - unpack_directory: t.Optional[str] = None + kubeconfig: pathlib.Path | None = None, + kubecontext: str | None = None, + kubeapiserver: str | None = None, + kubetoken: str | None = None, + unpack_directory: str | None = None, ): self._logger = logging.getLogger(__name__) self._default_timeout = default_timeout @@ -169,11 +174,11 @@ def _log_format(self, argument): else: return argument - async def run(self, command: t.List[str], input: t.Optional[bytes] = None) -> bytes: + async def run(self, command: list[str], input: bytes | None = None) -> bytes: # noqa: A002,C901 """ - Run the given Helm command with the given input as stdin and + Run the given Helm command with the given input as stdin and """ - command = [self._executable] + command + command = [self._executable, *command] if self._kubeconfig: command.extend(["--kubeconfig", self._kubeconfig]) if self._kubecontext: @@ -186,26 +191,25 @@ async def run(self, command: t.List[str], input: t.Optional[bytes] = None) -> by command.append("--kube-insecure-skip-tls-verify") # The command must be made up of str and bytes, so convert anything that isn't shell_formatted_command = shlex.join( - part if isinstance(part, (str, bytes)) else str(part) - for part in command + part if isinstance(part, (str, bytes)) else str(part) for part in command ) log_formatted_command = shlex.join(self._log_format(part) for part in command) self._logger.info("running command: %s", log_formatted_command) proc = await asyncio.create_subprocess_shell( shell_formatted_command, # Only make stdin a pipe if we have input to feed it - stdin = asyncio.subprocess.PIPE if input is not None else None, - stdout = asyncio.subprocess.PIPE, - stderr = asyncio.subprocess.PIPE + stdin=asyncio.subprocess.PIPE if input is not None else None, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, ) try: stdout, stderr = await proc.communicate(input) except asyncio.CancelledError: # If the asyncio task is cancelled, terminate the Helm process but let the - # process handle the termination and exit - # We occassionally see a ProcessLookupError here if the process finished between - # us being cancelled and terminating the process, which we ignore as that is our - # target state anyway + # process handle the termination and exit + # We occasionally see a ProcessLookupError here if the process finished + # between us being cancelled and terminating the process, which we ignore as + # that is our target state anyway try: proc.terminate() _ = await proc.communicate() @@ -223,7 +227,8 @@ async def run(self, command: t.List[str], input: t.Optional[bytes] = None) -> by if "context canceled" in stderr_str: error_cls = errors.CommandCancelledError # Any error referencing etcd is a connection error - # This must be before other rules, as it sometimes occurs alonside a not found error + # This must be before other rules, + # as it sometimes occurs alonside a not found error elif "etcdserver" in stderr_str: error_cls = errors.ConnectionError elif "release: not found" in stderr_str: @@ -232,7 +237,10 @@ async def run(self, command: t.List[str], input: t.Optional[bytes] = None) -> by error_cls = errors.FailedToRenderChartError elif "execution error" in stderr_str: error_cls = errors.FailedToRenderChartError - elif "rendered manifests contain a resource that already exists" in stderr_str: + elif ( + "rendered manifests contain a resource that already exists" + in stderr_str + ): error_cls = errors.ResourceAlreadyExistsError elif "is invalid" in stderr_str: error_cls = errors.InvalidResourceError @@ -250,10 +258,10 @@ async def diff_release( other_release_name: str, *, # The number of lines of context to show around each diff - context_lines: t.Optional[int] = None, - namespace: t.Optional[str] = None, + context_lines: int | None = None, + namespace: str | None = None, # Indicates whether to show secret values in the diff - show_secrets: bool = True + show_secrets: bool = True, ) -> str: """ Returns the diff between two releases created from the same chart. @@ -279,13 +287,13 @@ async def diff_revision( release_name: str, revision: int, # If not specified, the diff is with latest - other_revision: t.Optional[int] = None, + other_revision: int | None = None, *, # The number of lines of context to show around each diff - context_lines: t.Optional[int] = None, - namespace: t.Optional[str] = None, + context_lines: int | None = None, + namespace: str | None = None, # Indicates whether to show secret values in the diff - show_secrets: bool = True + show_secrets: bool = True, ) -> str: """ Returns the diff between two revisions of the specified release. @@ -313,13 +321,13 @@ async def diff_rollback( self, release_name: str, # The revision to simulate rolling back to - revision: t.Optional[int] = None, + revision: int | None = None, *, # The number of lines of context to show around each diff - context_lines: t.Optional[int] = None, - namespace: t.Optional[str] = None, + context_lines: int | None = None, + namespace: str | None = None, # Indicates whether to show secret values in the diff - show_secrets: bool = True + show_secrets: bool = True, ) -> str: """ Returns the diff that would result from rolling back the given release @@ -344,21 +352,21 @@ async def diff_rollback( async def diff_upgrade( self, release_name: str, - chart_ref: t.Union[pathlib.Path, str], - values: t.Optional[t.Dict[str, t.Any]] = None, + chart_ref: pathlib.Path | str, + values: dict[str, t.Any] | None = None, *, # The number of lines of context to show around each diff - context_lines: t.Optional[int] = None, + context_lines: int | None = None, devel: bool = False, dry_run: bool = False, - namespace: t.Optional[str] = None, + namespace: str | None = None, no_hooks: bool = False, - repo: t.Optional[str] = None, + repo: str | None = None, reset_values: bool = False, reuse_values: bool = False, # Indicates whether to show secret values in the diff show_secrets: bool = True, - version: t.Optional[str] = None + version: str | None = None, ) -> str: """ Returns the diff that would result from rolling back the given release @@ -372,10 +380,12 @@ async def diff_upgrade( "--allow-unreleased", "--no-color", "--normalize-manifests", - # Disable OpenAPI validation as we still want the diff to work when CRDs change + # Disable OpenAPI validation as we still want the diff to work when CRDs + # change "--disable-openapi-validation", # We pass the values using stdin - "--values", "-", + "--values", + "-", ] if context_lines is not None: command.extend(["--context", context_lines]) @@ -401,7 +411,8 @@ async def diff_upgrade( async def diff_version(self) -> str: """ - Returns the version of the Helm diff plugin (https://github.com/databus23/helm-diff). + Returns the version of the Helm diff plugin + (https://github.com/databus23/helm-diff). """ return (await self.run(["diff", "version"])).decode() @@ -409,8 +420,8 @@ async def get_chart_metadata( self, release_name: str, *, - namespace: t.Optional[str] = None, - revision: t.Optional[int] = None + namespace: str | None = None, + revision: int | None = None, ): """ Returns metadata for the chart that was used to deploy the release. @@ -422,21 +433,22 @@ async def get_chart_metadata( "all", release_name, # Use the chart metadata template - "--template", CHART_METADATA_TEMPLATE + "--template", + CHART_METADATA_TEMPLATE, ] if namespace: command.extend(["--namespace", namespace]) if revision is not None: command.extend(["--revision", revision]) - return yaml.load(await self.run(command), Loader = SafeLoader) + return yaml.load(await self.run(command), Loader=SafeLoader) async def get_hooks( self, release_name: str, *, - namespace: t.Optional[str] = None, - revision: t.Optional[int] = None - ) -> t.Iterable[t.Dict[str, t.Any]]: + namespace: str | None = None, + revision: int | None = None, + ) -> t.Iterable[dict[str, t.Any]]: """ Returns the hooks for the specified release. """ @@ -445,15 +457,15 @@ async def get_hooks( command.extend(["--revision", revision]) if namespace: command.extend(["--namespace", namespace]) - return yaml.load_all(await self.run(command), Loader = SafeLoader) + return yaml.load_all(await self.run(command), Loader=SafeLoader) async def get_resources( self, release_name: str, *, - namespace: t.Optional[str] = None, - revision: t.Optional[int] = None - ) -> t.Iterable[t.Dict[str, t.Any]]: + namespace: str | None = None, + revision: int | None = None, + ) -> t.Iterable[dict[str, t.Any]]: """ Returns the resources for the specified release. """ @@ -462,16 +474,16 @@ async def get_resources( command.extend(["--revision", revision]) if namespace: command.extend(["--namespace", namespace]) - return yaml.load_all(await self.run(command), Loader = SafeLoader) + return yaml.load_all(await self.run(command), Loader=SafeLoader) async def get_values( self, release_name: str, *, computed: bool = False, - namespace: t.Optional[str] = None, - revision: t.Optional[int] = None - ) -> t.Dict[str, t.Any]: + namespace: str | None = None, + revision: int | None = None, + ) -> dict[str, t.Any]: """ Returns the values for the specified release. @@ -491,8 +503,8 @@ async def history( release_name: str, *, max_revisions: int = 256, - namespace: t.Optional[str] = None - ) -> t.Iterable[t.Dict[str, t.Any]]: + namespace: str | None = None, + ) -> t.Iterable[dict[str, t.Any]]: """ Returns the historical revisions for the specified release. @@ -503,29 +515,29 @@ async def history( command.extend(["--namespace", namespace]) return json.loads(await self.run(command)) - async def install_or_upgrade( + async def install_or_upgrade( # noqa: C901 self, release_name: str, - chart_ref: t.Union[pathlib.Path, str], - values: t.Optional[t.Dict[str, t.Any]] = None, + chart_ref: pathlib.Path | str, + values: dict[str, t.Any] | None = None, *, atomic: bool = False, cleanup_on_fail: bool = False, create_namespace: bool = True, - description: t.Optional[str] = None, + description: str | None = None, devel: bool = False, dry_run: bool = False, force: bool = False, - namespace: t.Optional[str] = None, + namespace: str | None = None, no_hooks: bool = False, - repo: t.Optional[str] = None, + repo: str | None = None, reset_values: bool = False, reuse_values: bool = False, skip_crds: bool = False, - timeout: t.Union[int, str, None] = None, - version: t.Optional[str] = None, - wait: bool = False - ) -> t.Iterable[t.Dict[str, t.Any]]: + timeout: int | str | None = None, + version: str | None = None, + wait: bool = False, + ) -> t.Iterable[dict[str, t.Any]]: """ Installs or upgrades the specified release using the given chart and values. """ @@ -533,13 +545,17 @@ async def install_or_upgrade( "upgrade", release_name, chart_ref, - "--history-max", self._history_max_revisions, + "--history-max", + self._history_max_revisions, "--install", - "--output", "json", + "--output", + "json", # Use the default timeout unless an override is specified - "--timeout", timeout if timeout is not None else self._default_timeout, + "--timeout", + timeout if timeout is not None else self._default_timeout, # We send the values in on stdin - "--values", "-", + "--values", + "-", ] if atomic: command.append("--atomic") @@ -576,7 +592,7 @@ async def install_or_upgrade( async def list( self, *, - all: bool = False, + all: bool = False, # noqa: A002 all_namespaces: bool = False, include_deployed: bool = True, include_failed: bool = False, @@ -585,10 +601,10 @@ async def list( include_uninstalled: bool = False, include_uninstalling: bool = False, max_releases: int = 256, - namespace: t.Optional[str] = None, + namespace: str | None = None, sort_by_date: bool = False, - sort_reversed: bool = False - ) -> t.Iterable[t.Dict[str, t.Any]]: + sort_reversed: bool = False, + ) -> t.Iterable[dict[str, t.Any]]: """ Returns the list of releases that match the given options. """ @@ -619,19 +635,20 @@ async def list( async def pull( self, - chart_ref: t.Union[pathlib.Path, str], + chart_ref: pathlib.Path | str, *, devel: bool = False, - repo: t.Optional[str] = None, - version: t.Optional[str] = None + repo: str | None = None, + version: str | None = None, ) -> pathlib.Path: """ Fetch a chart from a remote location and unpack it locally. - Returns the path of the directory into which the chart was downloaded and unpacked. + Returns the path of the directory into which the chart was downloaded and + unpacked. """ # Make a directory to unpack into - destination = tempfile.mkdtemp(prefix = "helm.", dir = self._unpack_directory) + destination = tempfile.mkdtemp(prefix="helm.", dir=self._unpack_directory) command = ["pull", chart_ref, "--destination", destination, "--untar"] if devel: command.append("--devel") @@ -642,7 +659,7 @@ async def pull( await self.run(command) return pathlib.Path(destination).resolve() - async def repo_list(self) -> t.Iterable[t.Dict[str, t.Any]]: + async def repo_list(self) -> t.Iterable[dict[str, t.Any]]: """ Lists the available Helm repositories. """ @@ -665,7 +682,7 @@ async def repo_update(self, *names: str): Returns the repo list on success. """ - await self.run(["repo", "update", "--fail-on-repo-update-fail"] + list(names)) + await self.run(["repo", "update", "--fail-on-repo-update-fail", *names]) async def repo_remove(self, name: str): """ @@ -682,16 +699,16 @@ async def repo_remove(self, name: str): async def rollback( self, release_name: str, - revision: t.Optional[int], + revision: int | None, *, cleanup_on_fail: bool = False, dry_run: bool = False, force: bool = False, - namespace: t.Optional[str] = None, + namespace: str | None = None, no_hooks: bool = False, recreate_pods: bool = False, - timeout: t.Union[int, str, None] = None, - wait: bool = False + timeout: int | str | None = None, + wait: bool = False, ): """ Rollback the specified release to the specified revision. @@ -702,11 +719,15 @@ async def rollback( ] if revision is not None: command.append(revision) - command.extend([ - "--history-max", self._history_max_revisions, - # Use the default timeout unless an override is specified - "--timeout", timeout if timeout is not None else self._default_timeout, - ]) + command.extend( + [ + "--history-max", + self._history_max_revisions, + # Use the default timeout unless an override is specified + "--timeout", + timeout if timeout is not None else self._default_timeout, + ] + ) if cleanup_on_fail: command.append("--cleanup-on-fail") if dry_run: @@ -725,14 +746,15 @@ async def rollback( async def search( self, - search_keyword: t.Optional[str] = None, + search_keyword: str | None = None, *, all_versions: bool = False, devel: bool = False, - version_constraints: t.Optional[str] = None - ) -> t.Iterable[t.Dict[str, t.Any]]: + version_constraints: str | None = None, + ) -> t.Iterable[dict[str, t.Any]]: """ - Search the available Helm repositories for charts matching the specified constraints. + Search the available Helm repositories for charts matching the specified + constraints. """ command = ["search", "repo", "--output", "json"] if search_keyword: @@ -747,12 +769,12 @@ async def search( async def show_chart( self, - chart_ref: t.Union[pathlib.Path, str], + chart_ref: pathlib.Path | str, *, devel: bool = False, - repo: t.Optional[str] = None, - version: t.Optional[str] = None - ) -> t.Dict[str, t.Any]: + repo: str | None = None, + version: str | None = None, + ) -> dict[str, t.Any]: """ Returns the contents of Chart.yaml for the specified chart. """ @@ -763,21 +785,21 @@ async def show_chart( command.extend(["--repo", repo]) if version: command.extend(["--version", version]) - return yaml.load(await self.run(command), Loader = SafeLoader) + return yaml.load(await self.run(command), Loader=SafeLoader) async def show_crds( self, - chart_ref: t.Union[pathlib.Path, str], + chart_ref: pathlib.Path | str, *, devel: bool = False, - repo: t.Optional[str] = None, - version: t.Optional[str] = None - ) -> t.Iterable[t.Dict[str, t.Any]]: + repo: str | None = None, + version: str | None = None, + ) -> t.Iterable[dict[str, t.Any]]: """ Returns the CRDs for the specified chart. """ # Until https://github.com/helm/helm/issues/11261 is fixed, we must manually - # unpack the chart and parse the files in the ./crds directory ourselves + # unpack the chart and parse the files in the ./crds directory ourselves # This is what the implementation should be # command = ["show", "crds", chart_ref] # if devel: @@ -792,18 +814,17 @@ async def show_crds( ephemeral_path = None try: if repo: - # If a repo is given, assume that the chart ref is a chart name in that repo + # If a repo is given, assume that the chart ref is a chart name in that + # repo ephemeral_path = await self.pull( - chart_ref, - devel = devel, - repo = repo, - version = version + chart_ref, devel=devel, repo=repo, version=version ) chart_directory = next(ephemeral_path.glob("**/Chart.yaml")).parent else: - # If not, we have either a path (directory or archive) or a URL to a chart + # If not, we have either a path (directory or archive) or a URL to a + # chart try: - chart_path = pathlib.Path(chart_ref).resolve(strict = True) + chart_path = pathlib.Path(chart_ref).resolve(strict=True) except (TypeError, ValueError, FileNotFoundError): # Assume we have a URL that needs pulling ephemeral_path = await self.pull(chart_ref) @@ -813,10 +834,14 @@ async def show_crds( # Just make sure that the directory is a chart chart_directory = next(chart_path.glob("**/Chart.yaml")).parent else: - raise RuntimeError("local archive files are not currently supported") + raise RuntimeError( + "local archive files are not currently supported" + ) + def yaml_load_all(file): with file.open() as fh: - yield from yaml.load_all(fh, Loader = SafeLoader) + yield from yaml.load_all(fh, Loader=SafeLoader) + return [ crd for crd_file in chart_directory.glob("crds/**/*.yaml") @@ -828,11 +853,11 @@ def yaml_load_all(file): async def show_readme( self, - chart_ref: t.Union[pathlib.Path, str], + chart_ref: pathlib.Path | str, *, devel: bool = False, - repo: t.Optional[str] = None, - version: t.Optional[str] = None + repo: str | None = None, + version: str | None = None, ) -> str: """ Returns the README for the specified chart. @@ -848,12 +873,12 @@ async def show_readme( async def show_values( self, - chart_ref: t.Union[pathlib.Path, str], + chart_ref: pathlib.Path | str, *, devel: bool = False, - repo: t.Optional[str] = None, - version: t.Optional[str] = None - ) -> t.Dict[str, t.Any]: + repo: str | None = None, + version: str | None = None, + ) -> dict[str, t.Any]: """ Returns the default values for the specified chart. """ @@ -864,14 +889,14 @@ async def show_values( command.extend(["--repo", repo]) if version: command.extend(["--version", version]) - return yaml.load(await self.run(command), Loader = SafeLoader) + return yaml.load(await self.run(command), Loader=SafeLoader) async def status( self, release_name: str, *, - namespace: t.Optional[str] = None, - revision: t.Optional[int] = None, + namespace: str | None = None, + revision: int | None = None, ): """ Get the status of the specified release. @@ -886,17 +911,17 @@ async def status( async def template( self, release_name: str, - chart_ref: t.Union[pathlib.Path, str], - values: t.Optional[t.Dict[str, t.Any]] = None, + chart_ref: pathlib.Path | str, + values: dict[str, t.Any] | None = None, *, devel: bool = False, include_crds: bool = False, is_upgrade: bool = False, - namespace: t.Optional[str] = None, + namespace: str | None = None, no_hooks: bool = False, - repo: t.Optional[str] = None, - version: t.Optional[str] = None, - ) -> t.Iterable[t.Dict[str, t.Any]]: + repo: str | None = None, + version: str | None = None, + ) -> t.Iterable[dict[str, t.Any]]: """ Renders the chart templates and returns the resources. """ @@ -906,7 +931,8 @@ async def template( chart_ref, "--include-crds" if include_crds else "--skip-crds", # We send the values in on stdin - "--values", "-", + "--values", + "-", ] if devel: command.append("--devel") @@ -922,7 +948,7 @@ async def template( command.extend(["--version", version]) return yaml.load_all( await self.run(command, json.dumps(values or {}).encode()), - Loader = SafeLoader + Loader=SafeLoader, ) async def uninstall( @@ -931,10 +957,10 @@ async def uninstall( *, dry_run: bool = False, keep_history: bool = False, - namespace: t.Optional[str] = None, + namespace: str | None = None, no_hooks: bool = False, - timeout: t.Union[int, str, None] = None, - wait: bool = False + timeout: int | str | None = None, + wait: bool = False, ): """ Uninstall the specified release. @@ -943,7 +969,8 @@ async def uninstall( "uninstall", release_name, # Use the default timeout unless an override is specified - "--timeout", timeout if timeout is not None else self._default_timeout, + "--timeout", + timeout if timeout is not None else self._default_timeout, ] if dry_run: command.append("--dry-run") diff --git a/pyhelm3/errors.py b/pyhelm3/errors.py index 8fdfcf1..40c6d48 100644 --- a/pyhelm3/errors.py +++ b/pyhelm3/errors.py @@ -2,6 +2,7 @@ class Error(Exception): """ Raised when an error occurs with a Helm command. """ + def __init__(self, returncode: int, stdout: bytes, stderr: bytes): self.returncode = returncode self.stdout = stdout @@ -9,7 +10,7 @@ def __init__(self, returncode: int, stdout: bytes, stderr: bytes): super().__init__(stderr.decode()) -class ConnectionError(Error): +class ConnectionError(Error): # noqa: A001 """ Raised when there is a problem connecting to the Kubernetes API. """ diff --git a/pyhelm3/models.py b/pyhelm3/models.py index d03fdc6..47e9d4a 100644 --- a/pyhelm3/models.py +++ b/pyhelm3/models.py @@ -2,34 +2,37 @@ import enum import pathlib import typing as t +from typing import Annotated import yaml - +from pydantic import ( + AnyUrl as PydanticAnyUrl, +) from pydantic import ( BaseModel, - TypeAdapter, - Field, - PrivateAttr, DirectoryPath, + Field, FilePath, - AnyUrl as PydanticAnyUrl, - HttpUrl as PydanticHttpUrl, + PrivateAttr, + TypeAdapter, constr, - field_validator + field_validator, +) +from pydantic import ( + HttpUrl as PydanticHttpUrl, ) - -from typing_extensions import Annotated -OCIPath = Annotated[str, Field(pattern=r"oci:\/\/*")] - from pydantic.functional_validators import AfterValidator from .command import Command, SafeLoader +OCIPath = Annotated[str, Field(pattern=r"oci:\/\/*")] + class ModelWithCommand(BaseModel): """ Base class for a model that has a Helm command object. """ + # The command object that is used to invoke Helm _command: Command = PrivateAttr() @@ -39,21 +42,23 @@ def __init__(self, _command: Command, **kwargs): #: Type for a non-empty string -NonEmptyString = constr(min_length = 1) +NonEmptyString = constr(min_length=1) #: Type for a name (chart or release) -Name = constr(pattern = r"^[a-z0-9-]+$") +Name = constr(pattern=r"^[a-z0-9-]+$") #: Type for a SemVer version -SemVerVersion = constr(pattern = r"^v?\d+\.\d+\.\d+(-[a-zA-Z0-9\.\-]+)?(\+[a-zA-Z0-9\.\-]+)?$") +SemVerVersion = constr( + pattern=r"^v?\d+\.\d+\.\d+(-[a-zA-Z0-9\.\-]+)?(\+[a-zA-Z0-9\.\-]+)?$" +) #: Type variables for forward references to the chart and release types -ChartType = t.TypeVar("ChartType", bound = "Chart") -ReleaseType = t.TypeVar("ReleaseType", bound = "Release") -ReleaseRevisionType = t.TypeVar("ReleaseRevisionType", bound = "ReleaseRevision") +ChartType = t.TypeVar("ChartType", bound="Chart") +ReleaseType = t.TypeVar("ReleaseType", bound="Release") +ReleaseRevisionType = t.TypeVar("ReleaseRevisionType", bound="ReleaseRevision") #: Type annotation for validating a string using a Pydantic type @@ -71,37 +76,33 @@ class ChartDependency(BaseModel): """ Model for a chart dependency. """ - name: Name = Field( - ..., - description = "The name of the chart." - ) + + name: Name = Field(..., description="The name of the chart.") version: NonEmptyString = Field( - ..., - description = "The version of the chart. Can be a SemVer range." - ) - repository: str = Field( - "", - description = "The repository URL or alias." + ..., description="The version of the chart. Can be a SemVer range." ) - condition: t.Optional[NonEmptyString] = Field( + repository: str = Field("", description="The repository URL or alias.") + condition: NonEmptyString | None = Field( None, - description = "A yaml path that resolves to a boolean, used for enabling/disabling the chart." - ) - tags: t.List[NonEmptyString] = Field( - default_factory = list, - description = "Tags can be used to group charts for enabling/disabling together." - ) - import_values: t.List[t.Union[t.Dict[str, str], str]] = Field( - default_factory = list, - alias = "import-values", - description = ( + description=( + "A yaml path that resolves to a boolean, " + "used for enabling/disabling the chart." + ), + ) + tags: list[NonEmptyString] = Field( + default_factory=list, + description="Tags can be used to group charts for enabling/disabling together.", + ) + import_values: list[dict[str, str] | str] = Field( + default_factory=list, + alias="import-values", + description=( "Mapping of source values to parent key to be imported. " "Each item can be a string or pair of child/parent sublist items." - ) + ), ) - alias: t.Optional[NonEmptyString] = Field( - None, - description = "Alias to be used for the chart." + alias: NonEmptyString | None = Field( + None, description="Alias to be used for the chart." ) @@ -109,89 +110,61 @@ class ChartMaintainer(BaseModel): """ Model for the maintainer of a chart. """ - name: NonEmptyString = Field( - ..., - description = "The maintainer's name." - ) - email: t.Optional[NonEmptyString] = Field( - None, - description = "The maintainer's email." - ) - url: t.Optional[AnyUrl] = Field( - None, - description = "A URL for the maintainer." - ) + + name: NonEmptyString = Field(..., description="The maintainer's name.") + email: NonEmptyString | None = Field(None, description="The maintainer's email.") + url: AnyUrl | None = Field(None, description="A URL for the maintainer.") class ChartMetadata(BaseModel): """ Model for chart metadata, from Chart.yaml. """ + api_version: t.Literal["v1", "v2"] = Field( - ..., - alias = "apiVersion", - description = "The chart API version." - ) - name: Name = Field( - ..., - description = "The name of the chart." + ..., alias="apiVersion", description="The chart API version." ) - version: SemVerVersion = Field( - ..., - description = "The version of the chart." - ) - kube_version: t.Optional[NonEmptyString] = Field( + name: Name = Field(..., description="The name of the chart.") + version: SemVerVersion = Field(..., description="The version of the chart.") + kube_version: NonEmptyString | None = Field( None, - alias = "kubeVersion", - description = "A SemVer range of compatible Kubernetes versions for the chart." + alias="kubeVersion", + description="A SemVer range of compatible Kubernetes versions for the chart.", ) - description: t.Optional[NonEmptyString] = Field( - None, - description = "A single-sentence description of the chart." + description: NonEmptyString | None = Field( + None, description="A single-sentence description of the chart." ) type: t.Literal["application", "library"] = Field( - "application", - description = "The type of the chart." + "application", description="The type of the chart." ) - keywords: t.List[NonEmptyString] = Field( - default_factory = list, - description = "List of keywords for the chart." + keywords: list[NonEmptyString] = Field( + default_factory=list, description="List of keywords for the chart." ) - home: t.Optional[HttpUrl] = Field( - None, - description = "The URL of th home page for the chart." + home: HttpUrl | None = Field( + None, description="The URL of th home page for the chart." ) - sources: t.List[AnyUrl] = Field( - default_factory = list, - description = "List of URLs to source code for this chart." + sources: list[AnyUrl] = Field( + default_factory=list, description="List of URLs to source code for this chart." ) - dependencies: t.List[ChartDependency] = Field( - default_factory = list, - description = "List of the chart dependencies." + dependencies: list[ChartDependency] = Field( + default_factory=list, description="List of the chart dependencies." ) - maintainers: t.List[ChartMaintainer] = Field( - default_factory = list, - description = "List of maintainers for the chart." + maintainers: list[ChartMaintainer] = Field( + default_factory=list, description="List of maintainers for the chart." ) - icon: t.Optional[HttpUrl] = Field( - None, - description = "URL to an SVG or PNG image to be used as an icon." + icon: HttpUrl | None = Field( + None, description="URL to an SVG or PNG image to be used as an icon." ) - app_version: t.Optional[NonEmptyString] = Field( + app_version: NonEmptyString | None = Field( None, - alias = "appVersion", - description = ( - "The version of the app that this chart deploys. " - "SemVer is not required." - ) + alias="appVersion", + description=( + "The version of the app that this chart deploys. SemVer is not required." + ), ) - deprecated: bool = Field( - False, - description = "Whether this chart is deprecated." - ) - annotations: t.Dict[str, str] = Field( - default_factory = dict, - description = "Annotations for the chart." + deprecated: bool = Field(False, description="Whether this chart is deprecated.") + annotations: dict[str, str] = Field( + default_factory=dict, description="Annotations for the chart." ) @@ -199,26 +172,27 @@ class Chart(ModelWithCommand): """ Model for a reference to a chart. """ - ref: t.Union[DirectoryPath, FilePath, HttpUrl, Name, OCIPath] = Field( + + ref: DirectoryPath | FilePath | HttpUrl | Name | OCIPath = Field( ..., - description = ( + description=( "The chart reference. " "Can be a chart directory or a packaged chart archive on the local " "filesystem, the URL of a packaged chart or the name of a chart. " "When a name is given, repo must also be given and version may optionally " "be given." - ) + ), ) - repo: t.Optional[HttpUrl] = Field(None, description = "The repository URL.") - metadata: ChartMetadata = Field(..., description = "The metadata for the chart.") + repo: HttpUrl | None = Field(None, description="The repository URL.") + metadata: ChartMetadata = Field(..., description="The metadata for the chart.") # Private attributes used to cache attributes _readme: str = PrivateAttr(None) - _crds: t.List[t.Dict[str, t.Any]] = PrivateAttr(None) - _values: t.Dict[str, t.Any] = PrivateAttr(None) + _crds: list[dict[str, t.Any]] = PrivateAttr(None) + _values: dict[str, t.Any] = PrivateAttr(None) @field_validator("ref") - def ref_is_abspath(cls, v): + def ref_is_abspath(cls, v): # noqa: N805 """ If the ref is a path on the filesystem, make sure it is absolute. """ @@ -236,7 +210,7 @@ async def _run_command(self, command_method): if isinstance(self.ref, (pathlib.Path, HttpUrl)): return await method(self.ref) else: - return await method(self.ref, repo = self.repo, version = self.metadata.version) + return await method(self.ref, repo=self.repo, version=self.metadata.version) async def readme(self) -> str: """ @@ -246,7 +220,7 @@ async def readme(self) -> str: self._readme = await self._run_command("show_readme") return self._readme - async def crds(self) -> t.Iterable[t.Dict[str, t.Any]]: + async def crds(self) -> t.Iterable[dict[str, t.Any]]: """ Returns the CRDs for the chart. """ @@ -254,7 +228,7 @@ async def crds(self) -> t.Iterable[t.Dict[str, t.Any]]: self._crds = list(await self._run_command("show_crds")) return self._crds - async def values(self) -> t.Dict[str, t.Any]: + async def values(self) -> dict[str, t.Any]: """ Returns the values for the chart. """ @@ -267,25 +241,17 @@ class Release(ModelWithCommand): """ Model for a Helm release. """ - name: Name = Field( - ..., - description = "The name of the release." - ) - namespace: Name = Field( - ..., - description = "The namespace of the release." - ) + + name: Name = Field(..., description="The name of the release.") + namespace: Name = Field(..., description="The namespace of the release.") async def current_revision(self) -> ReleaseRevisionType: """ Returns the current revision for the release. """ return ReleaseRevision._from_status( - await self._command.status( - self.name, - namespace = self.namespace - ), - self._command + await self._command.status(self.name, namespace=self.namespace), + self._command, ) async def revision(self, revision: int) -> ReleaseRevisionType: @@ -294,62 +260,61 @@ async def revision(self, revision: int) -> ReleaseRevisionType: """ return ReleaseRevision._from_status( await self._command.status( - self.name, - namespace = self.namespace, - revision = revision + self.name, namespace=self.namespace, revision=revision ), - self._command + self._command, ) - async def history(self, max_revisions: int = 256) -> t.Iterable[ReleaseRevisionType]: + async def history( + self, max_revisions: int = 256 + ) -> t.Iterable[ReleaseRevisionType]: """ Returns all the revisions for the release. """ history = await self._command.history( - self.name, - max_revisions = max_revisions, - namespace = self.namespace + self.name, max_revisions=max_revisions, namespace=self.namespace ) return ( ReleaseRevision( self._command, - release = self, - revision = revision["revision"], - status = revision["status"], - updated = revision["updated"], - description = revision.get("description") + release=self, + revision=revision["revision"], + status=revision["status"], + updated=revision["updated"], + description=revision.get("description"), ) for revision in history ) async def rollback( self, - revision: t.Optional[int] = None, + revision: int | None = None, *, cleanup_on_fail: bool = False, dry_run: bool = False, force: bool = False, no_hooks: bool = False, recreate_pods: bool = False, - timeout: t.Union[int, str, None] = None, - wait: bool = False + timeout: int | str | None = None, + wait: bool = False, ) -> ReleaseRevisionType: """ - Rollback this release to the specified version and return the resulting revision. + Rollback this release to the specified version and return the resulting + revision. If no revision is specified, it will rollback to the previous release. """ await self._command.rollback( self.name, revision, - cleanup_on_fail = cleanup_on_fail, - dry_run = dry_run, - force = force, - namespace = self.namespace, - no_hooks = no_hooks, - recreate_pods = recreate_pods, - timeout = timeout, - wait = wait + cleanup_on_fail=cleanup_on_fail, + dry_run=dry_run, + force=force, + namespace=self.namespace, + no_hooks=no_hooks, + recreate_pods=recreate_pods, + timeout=timeout, + wait=wait, ) return await self.current_revision() @@ -358,9 +323,9 @@ async def simulate_rollback( revision: int, *, # The number of lines of context to show around each diff - context_lines: t.Optional[int] = None, + context_lines: int | None = None, # Indicates whether to show secret values in the diff - show_secrets: bool = True + show_secrets: bool = True, ) -> str: """ Simulate a rollback to the specified revision and return the diff. @@ -368,18 +333,18 @@ async def simulate_rollback( return await self._command.diff_rollback( self.name, revision, - context_lines = context_lines, - namespace = self.namespace, - show_secrets = show_secrets + context_lines=context_lines, + namespace=self.namespace, + show_secrets=show_secrets, ) async def simulate_upgrade( self, chart: Chart, - values: t.Optional[t.Dict[str, t.Any]] = None, + values: dict[str, t.Any] | None = None, *, # The number of lines of context to show around each diff - context_lines: t.Optional[int] = None, + context_lines: int | None = None, dry_run: bool = False, no_hooks: bool = False, reset_values: bool = False, @@ -395,58 +360,59 @@ async def simulate_upgrade( chart.ref, values, # The number of lines of context to show around each diff - context_lines = context_lines, - dry_run = dry_run, - namespace = self.namespace, - no_hooks = no_hooks, - repo = chart.repo, - reset_values = reset_values, - reuse_values = reuse_values, - show_secrets = show_secrets, - version = chart.metadata.version + context_lines=context_lines, + dry_run=dry_run, + namespace=self.namespace, + no_hooks=no_hooks, + repo=chart.repo, + reset_values=reset_values, + reuse_values=reuse_values, + show_secrets=show_secrets, + version=chart.metadata.version, ) async def upgrade( self, chart: Chart, - values: t.Optional[t.Dict[str, t.Any]] = None, + values: dict[str, t.Any] | None = None, *, atomic: bool = False, cleanup_on_fail: bool = False, - description: t.Optional[str] = None, + description: str | None = None, dry_run: bool = False, force: bool = False, no_hooks: bool = False, reset_values: bool = False, reuse_values: bool = False, skip_crds: bool = False, - timeout: t.Union[int, str, None] = None, - wait: bool = False + timeout: int | str | None = None, + wait: bool = False, ) -> ReleaseRevisionType: """ - Upgrade this release using the given chart and values and return the new revision. + Upgrade this release using the given chart and values and return the new + revision. """ return ReleaseRevision._from_status( await self._command.install_or_upgrade( self.name, chart.ref, values, - atomic = atomic, - cleanup_on_fail = cleanup_on_fail, - description = description, - dry_run = dry_run, - force = force, - namespace = self.namespace, - no_hooks = no_hooks, - repo = chart.repo, - reset_values = reset_values, - reuse_values = reuse_values, - skip_crds = skip_crds, - timeout = timeout, - version = chart.metadata.version, - wait = wait + atomic=atomic, + cleanup_on_fail=cleanup_on_fail, + description=description, + dry_run=dry_run, + force=force, + namespace=self.namespace, + no_hooks=no_hooks, + repo=chart.repo, + reset_values=reset_values, + reuse_values=reuse_values, + skip_crds=skip_crds, + timeout=timeout, + version=chart.metadata.version, + wait=wait, ), - self._command + self._command, ) async def uninstall( @@ -455,20 +421,20 @@ async def uninstall( dry_run: bool = False, keep_history: bool = False, no_hooks: bool = False, - timeout: t.Union[int, str, None] = None, - wait: bool = False + timeout: int | str | None = None, + wait: bool = False, ): """ Uninstalls this release. """ await self._command.uninstall( self.name, - dry_run = dry_run, - keep_history = keep_history, - namespace = self.namespace, - no_hooks = no_hooks, - timeout = timeout, - wait = wait + dry_run=dry_run, + keep_history=keep_history, + namespace=self.namespace, + no_hooks=no_hooks, + timeout=timeout, + wait=wait, ) @@ -476,6 +442,7 @@ class ReleaseRevisionStatus(str, enum.Enum): """ Enumeration of possible release statuses. """ + #: Indicates that the revision is in an uncertain state UNKNOWN = "unknown" #: Indicates that the revision has been pushed to Kubernetes @@ -500,6 +467,7 @@ class HookEvent(str, enum.Enum): """ Enumeration of possible hook events. """ + PRE_INSTALL = "pre-install" POST_INSTALL = "post-install" PRE_DELETE = "pre-delete" @@ -515,6 +483,7 @@ class HookDeletePolicy(str, enum.Enum): """ Enumeration of possible delete policies for a hook. """ + HOOK_SUCCEEDED = "hook-succeeded" HOOK_FAILED = "hook-failed" HOOK_BEFORE_HOOK_CREATION = "before-hook-creation" @@ -524,6 +493,7 @@ class HookPhase(str, enum.Enum): """ Enumeration of possible phases for a hook. """ + #: Indicates that a hook is in an unknown state UNKNOWN = "Unknown" #: Indicates that a hook is currently executing @@ -538,33 +508,20 @@ class Hook(BaseModel): """ Model for a hook. """ - name: NonEmptyString = Field( - ..., - description = "The name of the hook." - ) - phase: HookPhase = Field( - HookPhase.UNKNOWN, - description = "The phase of the hook." - ) - kind: NonEmptyString = Field( - ..., - description = "The kind of the hook." - ) + + name: NonEmptyString = Field(..., description="The name of the hook.") + phase: HookPhase = Field(HookPhase.UNKNOWN, description="The phase of the hook.") + kind: NonEmptyString = Field(..., description="The kind of the hook.") path: NonEmptyString = Field( ..., - description = "The chart-relative path to the template that produced the hook." - ) - resource: t.Dict[str, t.Any] = Field( - ..., - description = "The resource for the hook." + description="The chart-relative path to the template that produced the hook.", ) - events: t.List[HookEvent] = Field( - default_factory = list, - description = "The events that the hook fires on." + resource: dict[str, t.Any] = Field(..., description="The resource for the hook.") + events: list[HookEvent] = Field( + default_factory=list, description="The events that the hook fires on." ) - delete_policies: t.List[HookDeletePolicy] = Field( - default_factory = list, - description = "The delete policies for the hook." + delete_policies: list[HookDeletePolicy] = Field( + default_factory=list, description="The delete policies for the hook." ) @@ -572,36 +529,29 @@ class ReleaseRevision(ModelWithCommand): """ Model for a revision of a release. """ + release: ReleaseType = Field( - ..., - description = "The parent release of this revision." - ) - revision: int = Field( - ..., - description = "The revision number of this revision." + ..., description="The parent release of this revision." ) + revision: int = Field(..., description="The revision number of this revision.") status: ReleaseRevisionStatus = Field( - ..., - description = "The status of the revision." + ..., description="The status of the revision." ) updated: datetime.datetime = Field( - ..., - description = "The time at which this revision was updated." + ..., description="The time at which this revision was updated." ) - description: t.Optional[NonEmptyString] = Field( - None, - description = "'Log entry' for this revision." + description: NonEmptyString | None = Field( + None, description="'Log entry' for this revision." ) - notes: t.Optional[NonEmptyString] = Field( - None, - description = "The rendered notes for this revision, if available." + notes: NonEmptyString | None = Field( + None, description="The rendered notes for this revision, if available." ) # Optional fields if they are known at creation time - chart_metadata_: t.Optional[ChartMetadata] = Field(None, alias = "chart_metadata") - hooks_: t.Optional[t.List[t.Dict[str, t.Any]]] = Field(None, alias = "hooks") - resources_: t.Optional[t.List[t.Dict[str, t.Any]]] = Field(None, alias = "resources") - values_: t.Optional[t.Dict[str, t.Any]] = Field(None, alias = "values") + chart_metadata_: ChartMetadata | None = Field(None, alias="chart_metadata") + hooks_: list[dict[str, t.Any]] | None = Field(None, alias="hooks") + resources_: list[dict[str, t.Any]] | None = Field(None, alias="resources") + values_: dict[str, t.Any] | None = Field(None, alias="values") def _set_from_status(self, status): # Statuses from install/upgrade have chart metadata embedded @@ -609,24 +559,24 @@ def _set_from_status(self, status): self.chart_metadata_ = ChartMetadata(**status["chart"]["metadata"]) self.hooks_ = [ Hook( - name = hook["name"], - phase = hook["last_run"].get("phase") or "Unknown", - kind = hook["kind"], - path = hook["path"], - resource = yaml.load(hook["manifest"], Loader = SafeLoader), - events = hook["events"], - delete_policies = hook.get("delete_policies", []) + name=hook["name"], + phase=hook["last_run"].get("phase") or "Unknown", + kind=hook["kind"], + path=hook["path"], + resource=yaml.load(hook["manifest"], Loader=SafeLoader), + events=hook["events"], + delete_policies=hook.get("delete_policies", []), ) for hook in status.get("hooks", []) ] - self.resources_ = list(yaml.load_all(status["manifest"], Loader = SafeLoader)) + self.resources_ = list(yaml.load_all(status["manifest"], Loader=SafeLoader)) async def _init_from_status(self): self._set_from_status( await self._command.status( self.release.name, - namespace = self.release.namespace, - revision = self.revision + namespace=self.release.namespace, + revision=self.revision, ) ) @@ -637,12 +587,12 @@ async def chart_metadata(self) -> ChartMetadata: if self.chart_metadata_ is None: metadata = await self._command.get_chart_metadata( self.release.name, - namespace = self.release.namespace, - revision = self.revision + namespace=self.release.namespace, + revision=self.revision, ) self.chart_metadata_ = ChartMetadata(**metadata) return self.chart_metadata_ - + async def hooks(self) -> t.Iterable[Hook]: """ Returns the hooks that were executed as part of this revision. @@ -651,7 +601,7 @@ async def hooks(self) -> t.Iterable[Hook]: await self._init_from_status() return self.hooks_ - async def resources(self) -> t.Iterable[t.Dict[str, t.Any]]: + async def resources(self) -> t.Iterable[dict[str, t.Any]]: """ Returns the resources that were created as part of this revision. """ @@ -659,15 +609,15 @@ async def resources(self) -> t.Iterable[t.Dict[str, t.Any]]: await self._init_from_status() return self.resources_ - async def values(self, computed: bool = False) -> t.Dict[str, t.Any]: + async def values(self, computed: bool = False) -> dict[str, t.Any]: """ Returns the values that were used for this revision. """ return await self._command.get_values( self.release.name, - computed = computed, - namespace = self.release.namespace, - revision = self.revision + computed=computed, + namespace=self.release.namespace, + revision=self.revision, ) async def refresh(self) -> ReleaseRevisionType: @@ -677,10 +627,10 @@ async def refresh(self) -> ReleaseRevisionType: return self.__class__._from_status( await self._command.status( self.release.name, - namespace = self.release.namespace, - revision = self.revision + namespace=self.release.namespace, + revision=self.revision, ), - self._command + self._command, ) async def diff( @@ -688,9 +638,9 @@ async def diff( other_revision: int, *, # The number of lines of context to show around each diff - context_lines: t.Optional[int] = None, + context_lines: int | None = None, # Indicates whether to show secret values in the diff - show_secrets: bool = True + show_secrets: bool = True, ) -> str: """ Returns the diff between this revision and the specified revision. @@ -699,28 +649,26 @@ async def diff( self.release.name, self.revision, other_revision, - context_lines = context_lines, - namespace = self.release.namespace, - show_secrets = show_secrets + context_lines=context_lines, + namespace=self.release.namespace, + show_secrets=show_secrets, ) @classmethod - def _from_status(cls, status: t.Dict[str, t.Any], command: Command): + def _from_status(cls, status: dict[str, t.Any], command: Command): """ Internal constructor to create a release revision from a status result. """ revision = ReleaseRevision( command, - release = Release( - command, - name = status["name"], - namespace = status["namespace"] + release=Release( + command, name=status["name"], namespace=status["namespace"] ), - revision = status["version"], - status = status["info"]["status"], - updated = status["info"]["last_deployed"], - description = status["info"].get("description"), - notes = status["info"].get("notes") + revision=status["version"], + status=status["info"]["status"], + updated=status["info"]["last_deployed"], + description=status["info"].get("description"), + notes=status["info"].get("notes"), ) revision._set_from_status(status) return revision From a8a7b782399e4deb397d96e2bbea9a705b2b5b8d Mon Sep 17 00:00:00 2001 From: bertiethorpe Date: Tue, 17 Jun 2025 16:18:45 +0100 Subject: [PATCH 3/5] fix tox.ini --- tox.ini | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tox.ini b/tox.ini index dcbcf16..b7dc9f8 100644 --- a/tox.ini +++ b/tox.ini @@ -2,7 +2,7 @@ minversion = 4.0.0 # We run autofix last, to ensure CI fails, # even though we do our best to autofix locally -envlist = py3,ruff,codespell, autofix +envlist = py3,ruff,codespell,autofix # TODO: fix mypy and add to envlist skipsdist = True @@ -35,7 +35,7 @@ commands = codespell {posargs} [testenv:ruff] description = Run Ruff checks commands = - ruff check {tox_root} {posargs} + ruff check {tox_root} ruff format {tox_root} --check [testenv:venv] From effaa2fc6d78425854433f6e1d4bc1bfdd0b9cf6 Mon Sep 17 00:00:00 2001 From: bertiethorpe Date: Tue, 17 Jun 2025 16:21:39 +0100 Subject: [PATCH 4/5] test in CI --- .github/workflows/tox.yaml | 45 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 .github/workflows/tox.yaml diff --git a/.github/workflows/tox.yaml b/.github/workflows/tox.yaml new file mode 100644 index 0000000..6c51c14 --- /dev/null +++ b/.github/workflows/tox.yaml @@ -0,0 +1,45 @@ +name: Tox unit tests + +on: + workflow_call: + inputs: + ref: + type: string + description: The ref to build. + required: true + +jobs: + build: + name: Tox unit tests and linting + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.10'] + + steps: + - name: Check out the repository + uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref || github.ref }} + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install tox + + - name: Test with tox + run: tox + + - name: Generate coverage reports + run: tox -e cover + + - name: Archive code coverage results + uses: actions/upload-artifact@v4 + with: + name: code-coverage-report + path: cover/ \ No newline at end of file From 5a27d11ba3b8ee719351f4b2e0604d4b29b286e7 Mon Sep 17 00:00:00 2001 From: bertiethorpe Date: Thu, 26 Jun 2025 14:07:14 +0100 Subject: [PATCH 5/5] pull_request_target CI trigger --- .github/workflows/tox.yaml | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/.github/workflows/tox.yaml b/.github/workflows/tox.yaml index 6c51c14..f9176f3 100644 --- a/.github/workflows/tox.yaml +++ b/.github/workflows/tox.yaml @@ -1,12 +1,21 @@ name: Tox unit tests on: - workflow_call: - inputs: - ref: - type: string - description: The ref to build. - required: true + # We use pull_request_target so that dependabot-created workflows can run + pull_request_target: + types: + - opened + - synchronize + - ready_for_review + - reopened + branches: + - main + +# Use the head ref for workflow concurrency, with cancellation +# This should mean that any previous workflows for a PR get cancelled when a new commit is pushed +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref }} + cancel-in-progress: true jobs: build: