From d64eb5395484bbda110cac64fa5f7a87182156db Mon Sep 17 00:00:00 2001 From: Ian Thompson Date: Tue, 28 Jul 2026 15:32:53 +0100 Subject: [PATCH 1/4] pyhelm3/models.py: Fix isinstance comparison of subscripted type The `t.Annotated[str, AfterValidator(validate_str_as(PydanticHttpUrl))]` construction cannot be used in `isinstance` for Python >=3.11 as it a subscripted type. In current versions of Python and pydantic it is recommended to just use the pydantic types unless the object absolutely requires handling as a string. That is not the case in pyhelm3 so use the pydantic types as is. Furthermore the Go code in Helm only validates that icon is a valid Url according to RFC 3986 (see https://github.com/helm/helm/blob/7e641d30a9355b43a9729d04c2771215ed926899/internal/chart/v3/lint/rules/chartfile.go#L193 for the implementation). Therefore use the pydantic `AnyUrl` for the icon field. Resolves GitHub issue #48 --- pyhelm3/models.py | 22 +++++----------------- 1 file changed, 5 insertions(+), 17 deletions(-) diff --git a/pyhelm3/models.py b/pyhelm3/models.py index ca8cdc8..ebaed60 100644 --- a/pyhelm3/models.py +++ b/pyhelm3/models.py @@ -4,20 +4,18 @@ import typing as t import yaml -from pydantic import AnyUrl as PydanticAnyUrl from pydantic import ( + AnyUrl, BaseModel, DirectoryPath, Field, FilePath, + HttpUrl, PrivateAttr, TypeAdapter, - UrlConstraints, constr, field_validator, ) -from pydantic import HttpUrl as PydanticHttpUrl -from pydantic.functional_validators import AfterValidator from typing_extensions import Annotated from .command import Command, SafeLoader @@ -68,16 +66,6 @@ def validate_str_as(validate_type): return lambda v: str(adapter.validate_python(v)) -class PydanticDataUrl(PydanticAnyUrl): - _constraints = UrlConstraints(allowed_schemes=["data"]) - - -#: Annotated string types for URLs -AnyUrl = t.Annotated[str, AfterValidator(validate_str_as(PydanticAnyUrl))] -HttpUrl = t.Annotated[str, AfterValidator(validate_str_as(PydanticHttpUrl))] -DataUrl = t.Annotated[str, AfterValidator(validate_str_as(PydanticDataUrl))] - - class ChartDependency(BaseModel): """ Model for a chart dependency. @@ -159,14 +147,14 @@ class ChartMetadata(BaseModel): maintainers: t.List[ChartMaintainer] = Field( default_factory=list, description="List of maintainers for the chart." ) - icon: t.Optional[HttpUrl | DataUrl] = Field( + icon: t.Optional[AnyUrl] = Field( None, description="URL to an SVG or PNG image to be used as an icon." ) app_version: t.Optional[NonEmptyString] = Field( None, alias="appVersion", description=( - "The version of the app that this chart deploys. " "SemVer is not required." + "The version of the app that this chart deploys. SemVer is not required." ), ) deprecated: bool = Field(False, description="Whether this chart is deprecated.") @@ -214,7 +202,7 @@ async def _run_command(self, command_method): """ method = getattr(self._command, command_method) # We only need the kwargs if the ref is not a direct reference - if isinstance(self.ref, (pathlib.Path, HttpUrl)): + if isinstance(self.ref, (HttpUrl, pathlib.Path)): return await method(self.ref) else: return await method(self.ref, repo=self.repo, version=self.metadata.version) From 3746311f2ba90ad82e203d27993ed8c536eefc41 Mon Sep 17 00:00:00 2001 From: Ian Thompson Date: Tue, 28 Jul 2026 15:49:43 +0100 Subject: [PATCH 2/4] tests: Add test coverage for creating Chart objects from different sources Add test cases to create Chart objects from a http url and a local path (as well as the existing OCI path). Also test that a data url is loaded and handled correctly. --- tests/test-chart/Chart.yaml | 7 +++++++ tests/test_chart.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 tests/test-chart/Chart.yaml diff --git a/tests/test-chart/Chart.yaml b/tests/test-chart/Chart.yaml new file mode 100644 index 0000000..0b5efaa --- /dev/null +++ b/tests/test-chart/Chart.yaml @@ -0,0 +1,7 @@ +apiVersion: v2 +name: test-chart +description: A Helm chart for Kubernetes +type: application +version: 0.1.0 +appVersion: "1.16.0" +icon: data://notreal \ No newline at end of file diff --git a/tests/test_chart.py b/tests/test_chart.py index def421f..3c77362 100644 --- a/tests/test_chart.py +++ b/tests/test_chart.py @@ -1,4 +1,7 @@ +import pathlib + import pytest +from pydantic import AnyUrl, HttpUrl from pyhelm3 import Client @@ -11,3 +14,29 @@ async def test_oci_chart(): ) assert chart.metadata.name == "etcd" + + +@pytest.mark.asyncio +async def test_http_chart(): + helm_client = Client() + chart = await helm_client.get_chart( + chart_ref="https://github.com/prometheus-community/helm-charts/releases/download/kube-prometheus-stack-87.20.0/kube-prometheus-stack-87.20.0.tgz", + ) + + # Check the chart is loaded correctly and metadata parsed + assert chart.metadata.name == "kube-prometheus-stack" + readme = await chart.readme() + assert isinstance(readme, str) + assert isinstance(chart.ref, HttpUrl) + + +@pytest.mark.asyncio +async def test_local_chart(): + helm_client = Client() + chart = await helm_client.get_chart( + chart_ref=pathlib.Path.cwd() / "tests/test-chart", + ) + # Check the chart is loaded correctly and icon url parsed + assert chart.metadata.name == "test-chart" + assert isinstance(chart.metadata.icon, AnyUrl) + assert str(chart.metadata.icon) == "data://notreal" From b89666efc3cb9cd960e9d218d235979ff61e1bc6 Mon Sep 17 00:00:00 2001 From: Ian Thompson Date: Tue, 28 Jul 2026 15:51:23 +0100 Subject: [PATCH 3/4] pyproject.toml: Update packages to resolve deprecation warnings from pytest Pytest is quite verbose about reporting warnings that arise during testing, update packages and deprecated function calls arising from test execution. --- pyhelm3/command.py | 2 +- pyproject.toml | 6 +++--- uv.lock | 47 ++++++++++++++++++++++++++++------------------ 3 files changed, 33 insertions(+), 22 deletions(-) diff --git a/pyhelm3/command.py b/pyhelm3/command.py index c1c81bc..84be760 100644 --- a/pyhelm3/command.py +++ b/pyhelm3/command.py @@ -1042,4 +1042,4 @@ def client_version(self) -> semver.VersionInfo: shell_formatted_command, capture_output=True, check=True, shell=True ) version_str = proc.stdout.decode().removeprefix("v") - return semver.parse_version_info(version_str) + return semver.Version.parse(version_str) diff --git a/pyproject.toml b/pyproject.toml index 99d0ddb..7d11058 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,8 +7,8 @@ license = "Apache-2.0" requires-python = ">=3.10" dependencies = [ "pyyaml >= 6.0.2,<7", - "pydantic >= 2.10.6,<3", - "semver >= 2.9.1,<3" + "pydantic >= 2.10.6", + "semver >= 3.0.0" ] [dependency-groups] @@ -16,7 +16,7 @@ dev = [ "black >= 26.5.1,<26.6", "hatch-vcs>=0.5.0", "pytest>=8.3.5,<9", - "pytest-asyncio>=0.25.3,<0.26", + "pytest-asyncio>=0.25.3", "ruff>=0.15.22,<0.16", ] diff --git a/uv.lock b/uv.lock index 200cbd0..da39daf 100644 --- a/uv.lock +++ b/uv.lock @@ -4,11 +4,20 @@ requires-python = ">=3.10" [[package]] name = "annotated-types" -version = "0.7.0" +version = "0.8.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, ] [[package]] @@ -155,11 +164,11 @@ wheels = [ [[package]] name = "platformdirs" -version = "4.10.0" +version = "4.11.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } +sdist = { url = "https://files.pythonhosted.org/packages/78/9b/560e4be8e26f6fd133a03630a8df0c663b9e8d61b4ade152b72005aec83b/platformdirs-4.11.0.tar.gz", hash = "sha256:0555d18370482847566ffabcaa53ad7c6c1c29f195989ae1ed634a05f76ea1e0", size = 31953, upload-time = "2026-07-21T13:09:36.565Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, + { url = "https://files.pythonhosted.org/packages/7d/68/d8d58938dfb1370b266a1a729e6d77a985be23689a0496498ee17b2cbf90/platformdirs-4.11.0-py3-none-any.whl", hash = "sha256:360ccded2b7fce0af0ff80cc8f5942a1c5d99b0e856033acb030bfc634709e74", size = 23247, upload-time = "2026-07-21T13:09:35.422Z" }, ] [[package]] @@ -331,9 +340,9 @@ dev = [ [package.metadata] requires-dist = [ - { name = "pydantic", specifier = ">=2.10.6,<3" }, + { name = "pydantic", specifier = ">=2.10.6" }, { name = "pyyaml", specifier = ">=6.0.2,<7" }, - { name = "semver", specifier = ">=2.9.1,<3" }, + { name = "semver", specifier = ">=3.0.0" }, ] [package.metadata.requires-dev] @@ -341,7 +350,7 @@ dev = [ { name = "black", specifier = ">=26.5.1,<26.6" }, { name = "hatch-vcs", specifier = ">=0.5.0" }, { name = "pytest", specifier = ">=8.3.5,<9" }, - { name = "pytest-asyncio", specifier = ">=0.25.3,<0.26" }, + { name = "pytest-asyncio", specifier = ">=0.25.3" }, { name = "ruff", specifier = ">=0.15.22,<0.16" }, ] @@ -365,14 +374,16 @@ wheels = [ [[package]] name = "pytest-asyncio" -version = "0.25.3" +version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f2/a8/ecbc8ede70921dd2f544ab1cadd3ff3bf842af27f87bbdea774c7baa1d38/pytest_asyncio-0.25.3.tar.gz", hash = "sha256:fc1da2cf9f125ada7e710b4ddad05518d4cee187ae9412e9ac9271003497f07a", size = 54239, upload-time = "2025-01-28T18:37:58.729Z" } +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/67/17/3493c5624e48fd97156ebaec380dcaafee9506d7e2c46218ceebbb57d7de/pytest_asyncio-0.25.3-py3-none-any.whl", hash = "sha256:9e89518e0f9bd08928f97a3482fdc4e244df17529460bc038291ccaf8f85c7c3", size = 19467, upload-time = "2025-01-28T18:37:56.798Z" }, + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, ] [[package]] @@ -505,11 +516,11 @@ wheels = [ [[package]] name = "semver" -version = "2.13.0" +version = "3.0.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/31/a9/b61190916030ee9af83de342e101f192bbb436c59be20a4cb0cdb7256ece/semver-2.13.0.tar.gz", hash = "sha256:fa0fe2722ee1c3f57eac478820c3a5ae2f624af8264cbdf9000c980ff7f75e3f", size = 45816, upload-time = "2020-10-20T20:16:54.454Z" } +sdist = { url = "https://files.pythonhosted.org/packages/72/d1/d3159231aec234a59dd7d601e9dd9fe96f3afff15efd33c1070019b26132/semver-3.0.4.tar.gz", hash = "sha256:afc7d8c584a5ed0a11033af086e8af226a9c0b206f313e0301f8dd7b6b589602", size = 269730, upload-time = "2025-01-24T13:19:27.617Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/70/b84f9944a03964a88031ef6ac219b6c91e8ba2f373362329d8770ef36f02/semver-2.13.0-py2.py3-none-any.whl", hash = "sha256:ced8b23dceb22134307c1b8abfa523da14198793d9787ac838e70e29e77458d4", size = 12901, upload-time = "2020-10-20T20:16:52.583Z" }, + { url = "https://files.pythonhosted.org/packages/a6/24/4d91e05817e92e3a61c8a21e08fd0f390f5301f1c448b137c57c4bc6e543/semver-3.0.4-py3-none-any.whl", hash = "sha256:9c824d87ba7f7ab4a1890799cec8596f15c1241cb473404ea1cb0c55e4b04746", size = 17912, upload-time = "2025-01-24T13:19:24.949Z" }, ] [[package]] @@ -523,7 +534,7 @@ wheels = [ [[package]] name = "setuptools-scm" -version = "10.2.0" +version = "10.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "packaging" }, @@ -532,9 +543,9 @@ dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.11'" }, { name = "vcs-versioning" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/61/fb/6b22ba201305ec33fa68c1419e984abe1aaee1b236a5e1186cc805738e95/setuptools_scm-10.2.0.tar.gz", hash = "sha256:ec8ea1738b92e42146a46e29a0e9de9ad462744c63cf9778677b95dfd605adde", size = 67319, upload-time = "2026-06-25T05:27:10.243Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5d/b1/d0b97ffd2856a7d19c63024a89fb84813cb9d2ed7fa8fdbedf9e2f13a9ab/setuptools_scm-10.2.1.tar.gz", hash = "sha256:4fa7dd82cf8c800df59c9a288c90299b1657ff1ecfc3f5cc00287c5dbf5e27a9", size = 154237, upload-time = "2026-07-21T08:08:04.553Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/46/b4/21180f6fbbf7ed2043bc353608c7b9cc6f7b3d342ad8c287a097e5ca8477/setuptools_scm-10.2.0-py3-none-any.whl", hash = "sha256:6cc5ac7da8e54d74e43503be2fa023eb7a1ca820d5c318e6ae20efc48110d6c7", size = 27741, upload-time = "2026-06-25T05:27:09.037Z" }, + { url = "https://files.pythonhosted.org/packages/c3/b4/02ac1d9833b882c87b3a4e82703e70eac4ab33d0d15fb123e60bb01f3bc1/setuptools_scm-10.2.1-py3-none-any.whl", hash = "sha256:b7c82f4102d389ee57dc66ccdb4f9b4bca3c40ba83b43f1f63d68ccd72db2580", size = 29078, upload-time = "2026-07-21T08:08:03.293Z" }, ] [[package]] From 53ebb5da19fca8118f8a32a42c6ac9d40317b7e9 Mon Sep 17 00:00:00 2001 From: Ian Thompson Date: Tue, 28 Jul 2026 15:57:34 +0100 Subject: [PATCH 4/4] .github/workflows/pytest.yaml: Run pytests in CI --- .github/workflows/pytest.yaml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 .github/workflows/pytest.yaml diff --git a/.github/workflows/pytest.yaml b/.github/workflows/pytest.yaml new file mode 100644 index 0000000..14302d6 --- /dev/null +++ b/.github/workflows/pytest.yaml @@ -0,0 +1,16 @@ +name: Run pytest tests +on: + push: + +jobs: + lint_python: + runs-on: ubuntu-latest + steps: + - name: Check out the repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up uv + uses: astral-sh/setup-uv@eb1897b8dc4b5d5bfe39a428a8f2304605e0983c # v7.0.0 + + - name: Run pytest + run: uv run pytest