From 1d9bec064767167a3688a3c3880dfc3f682d3017 Mon Sep 17 00:00:00 2001 From: tdstein Date: Tue, 12 Nov 2024 09:52:39 -0500 Subject: [PATCH 01/13] fix: raise AttributeError instead of returning None on __getattr__. The __getattr__ method should always raise an AttributeError if the value does not exist instead of returning None. The existing implementation causes issues when converting to a Pandas DataFrame --- src/posit/connect/resources.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/posit/connect/resources.py b/src/posit/connect/resources.py index 2e54ecc2..ed0de84e 100644 --- a/src/posit/connect/resources.py +++ b/src/posit/connect/resources.py @@ -40,7 +40,7 @@ def __getattr__(self, name): stacklevel=2, ) return self[name] - return None + raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{name}'") def update(self, *args, **kwargs): super().update(*args, **kwargs) From 7778028181c2e0c29495b64e199373dd7894c09f Mon Sep 17 00:00:00 2001 From: tdstein Date: Thu, 31 Oct 2024 12:32:24 -0400 Subject: [PATCH 02/13] feat: add packages attribute to content --- src/posit/connect/content.py | 1 + src/posit/connect/packages.py | 89 +++++++++++++++++++ tests/posit/connect/test_packages.py | 128 +++++++++++++++++++++++++++ 3 files changed, 218 insertions(+) create mode 100644 src/posit/connect/packages.py create mode 100644 tests/posit/connect/test_packages.py diff --git a/src/posit/connect/content.py b/src/posit/connect/content.py index 9dc51fad..9815a399 100644 --- a/src/posit/connect/content.py +++ b/src/posit/connect/content.py @@ -13,6 +13,7 @@ from .env import EnvVars from .jobs import JobsMixin from .oauth.associations import ContentItemAssociations +from .packages import PackagesMixin from .permissions import Permissions from .resources import Resource, ResourceParameters, Resources from .vanities import VanityMixin diff --git a/src/posit/connect/packages.py b/src/posit/connect/packages.py new file mode 100644 index 00000000..a17362a5 --- /dev/null +++ b/src/posit/connect/packages.py @@ -0,0 +1,89 @@ +from typing import Literal, Optional, Sequence, TypedDict + +from typing_extensions import NotRequired, Required, Unpack + +from .resources import Resource, ResourceParameters, Resources + + +class Package(Resource): + """A package resource.""" + + class PackageAttributes(TypedDict): + """Package attributes.""" + + language: Required[Literal["r", "python"]] + name: Required[str] + version: Required[str] + hash: NotRequired[str] + + def __init__(self, params: ResourceParameters, **kwargs: Unpack[PackageAttributes]): + super().__init__(params, **kwargs) + + +class Packages(Resources, Sequence[Package]): + """A collection of packages.""" + + def __init__(self, params, endpoint): + super().__init__(params) + self._endpoint = endpoint + self._packages = [] + self.reload() + + def __getitem__(self, index): + """Retrieve an item or slice from the sequence.""" + return self._packages[index] + + def __len__(self): + """Return the length of the sequence.""" + return len(self._packages) + + def __repr__(self): + """Return the string representation of the sequence.""" + return f"Packages({', '.join(map(str, self._packages))})" + + def count(self, value): + """Return the number of occurrences of a value in the sequence.""" + return self._packages.count(value) + + def index(self, value, start=0, stop=None): + """Return the index of the first occurrence of a value in the sequence.""" + if stop is None: + stop = len(self._packages) + return self._packages.index(value, start, stop) + + def reload(self) -> "Packages": + """Reload packages from the Connect server. + + Returns + ------- + List[Package] + """ + response = self.params.session.get(self._endpoint) + results = response.json() + packages = [Package(self.params, **result) for result in results] + self._packages = packages + return self + + +class PackagesMixin(Resource): + """Mixin class to add a packages to a resource.""" + + class HasGuid(TypedDict): + """Has a guid.""" + + guid: Required[str] + + def __init__(self, params: ResourceParameters, **kwargs: Unpack[HasGuid]): + super().__init__(params, **kwargs) + self._guid = kwargs["guid"] + self._packages: Optional[Packages] = None + + @property + def packages(self) -> Packages: + """Get the packages.""" + if self._packages: + return self._packages + + endpoint = self.params.url + f"v1/content/{self._guid}/packages" + self._packages = Packages(self.params, endpoint) + return self._packages diff --git a/tests/posit/connect/test_packages.py b/tests/posit/connect/test_packages.py new file mode 100644 index 00000000..8b64a9e1 --- /dev/null +++ b/tests/posit/connect/test_packages.py @@ -0,0 +1,128 @@ +import requests +import responses + +from posit.connect.packages import PackagesMixin +from posit.connect.resources import ResourceParameters +from posit.connect.urls import Url + + +class TestPackagesMixin: + def setup_method(self): + self.url = Url("http://connect.example/__api__") + self.endpoint = self.url + "v1/content/1/packages" + self.session = requests.Session() + self.params = ResourceParameters(self.session, self.url) + self.mixin = PackagesMixin(self.params, guid="1") + + @responses.activate + def test_packages(self): + # mock + mock_get = responses.get( + self.endpoint, + json=[ + { + "language": "python", + "name": "posit-sdk", + "version": "0.5.1.dev3+gd4bba40.d20241016", + } + ], + ) + + # call + packages = self.mixin.packages + + # assert + assert mock_get.call_count == 1 + assert packages[0] == { + "language": "python", + "name": "posit-sdk", + "version": "0.5.1.dev3+gd4bba40.d20241016", + } + + @responses.activate + def test_packages_are_cached(self): + # mock + mock_get = responses.get( + self.endpoint, + json=[ + { + "language": "python", + "name": "posit-sdk", + "version": "0.5.1.dev3+gd4bba40.d20241016", + } + ], + ) + + # call attribute twice, the second call should be cached + self.mixin.packages + self.mixin.packages + + # assert called once + assert mock_get.call_count == 1 + + @responses.activate + def test_packages_count(self): + responses.get( + self.endpoint, + json=[ + { + "language": "python", + "name": "posit-sdk", + "version": "0.5.1.dev3+gd4bba40.d20241016", + } + ], + ) + + packages = self.mixin.packages + count = packages.count( + { + "language": "python", + "name": "posit-sdk", + "version": "0.5.1.dev3+gd4bba40.d20241016", + } + ) + + assert count == 1 + + @responses.activate + def test_packages_index(self): + responses.get( + self.endpoint, + json=[ + { + "language": "python", + "name": "posit-sdk", + "version": "0.5.1.dev3+gd4bba40.d20241016", + } + ], + ) + + packages = self.mixin.packages + index = packages.index( + { + "language": "python", + "name": "posit-sdk", + "version": "0.5.1.dev3+gd4bba40.d20241016", + } + ) + + assert index == 0 + + @responses.activate + def test_packages_repr(self): + responses.get( + self.endpoint, + json=[ + { + "language": "python", + "name": "posit-sdk", + "version": "0.5.1.dev3+gd4bba40.d20241016", + } + ], + ) + + packages = self.mixin.packages + assert ( + repr(packages) + == "Packages({'language': 'python', 'name': 'posit-sdk', 'version': '0.5.1.dev3+gd4bba40.d20241016'})" + ) From 436c8b9ab7c3431a5f0ceafc093a7befc316a784 Mon Sep 17 00:00:00 2001 From: tdstein Date: Mon, 4 Nov 2024 12:05:20 -0500 Subject: [PATCH 03/13] --wip-- [skip ci] --- integration/Makefile | 2 +- integration/tests/posit/connect/test_jobs.py | 14 ++ src/posit/connect/client.py | 5 + src/posit/connect/content.py | 2 +- src/posit/connect/packages.py | 111 ++++++-------- src/posit/connect/resources.py | 6 +- .../packages.json | 7 + tests/posit/connect/__api__/v1/packages.json | 11 ++ tests/posit/connect/test_packages.py | 143 +++++------------- 9 files changed, 123 insertions(+), 178 deletions(-) create mode 100644 tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/packages.json create mode 100644 tests/posit/connect/__api__/v1/packages.json diff --git a/integration/Makefile b/integration/Makefile index edc0ac50..65df52a3 100644 --- a/integration/Makefile +++ b/integration/Makefile @@ -137,5 +137,5 @@ test: set -o pipefail; \ CONNECT_VERSION=${CONNECT_VERSION} \ CONNECT_API_KEY="$(shell $(UV) run rsconnect bootstrap -i -s http://connect:3939 --raw)" \ - $(UV) run pytest -s --junit-xml=./reports/$(CONNECT_VERSION).xml | \ + $(UV) run pytest -s -k TestJobs --junit-xml=./reports/$(CONNECT_VERSION).xml | \ tee ./logs/$(CONNECT_VERSION).log; diff --git a/integration/tests/posit/connect/test_jobs.py b/integration/tests/posit/connect/test_jobs.py index 9617cf7e..6b978943 100644 --- a/integration/tests/posit/connect/test_jobs.py +++ b/integration/tests/posit/connect/test_jobs.py @@ -36,3 +36,17 @@ def test(self): jobs = content.jobs assert len(jobs) == 1 + + def test_find_by(self): + content = self.content + + path = Path("../../../resources/connect/bundles/example-quarto-minimal/bundle.tar.gz") + path = Path(__file__).parent / path + path = path.resolve() + path = str(path) + + bundle = content.bundles.create(path) + bundle.deploy() + + jobs = content.jobs.find_by(status=1) + assert len(jobs) == 1 diff --git a/src/posit/connect/client.py b/src/posit/connect/client.py index 85397c60..ba34aabe 100644 --- a/src/posit/connect/client.py +++ b/src/posit/connect/client.py @@ -14,6 +14,7 @@ from .groups import Groups from .metrics import Metrics from .oauth import OAuth +from .packages import Packages from .resources import ResourceParameters from .tasks import Tasks from .users import User, Users @@ -269,6 +270,10 @@ def oauth(self) -> OAuth: """ return OAuth(self.resource_params, self.cfg.api_key) + @property + def packages(self) -> Packages: + return Packages(self.ctx, "v1/packages") + @property def vanities(self) -> Vanities: return Vanities(self.resource_params) diff --git a/src/posit/connect/content.py b/src/posit/connect/content.py index 9815a399..750cd8e5 100644 --- a/src/posit/connect/content.py +++ b/src/posit/connect/content.py @@ -37,7 +37,7 @@ class ContentItemOwner(Resource): pass -class ContentItem(JobsMixin, VanityMixin, Resource): +class ContentItem(JobsMixin, PackagesMixin, VanityMixin, Resource): def __init__(self, /, params: ResourceParameters, **kwargs): ctx = Context(params.session, params.url) uid = kwargs["guid"] diff --git a/src/posit/connect/packages.py b/src/posit/connect/packages.py index a17362a5..1328c5ad 100644 --- a/src/posit/connect/packages.py +++ b/src/posit/connect/packages.py @@ -1,89 +1,64 @@ -from typing import Literal, Optional, Sequence, TypedDict +import posixpath +from typing import Optional, TypedDict, overload from typing_extensions import NotRequired, Required, Unpack -from .resources import Resource, ResourceParameters, Resources +from .resources import Active, ActiveFinderMethods, ActiveSequence -class Package(Resource): - """A package resource.""" - - class PackageAttributes(TypedDict): - """Package attributes.""" - - language: Required[Literal["r", "python"]] +class Package(Active): + class _Package(TypedDict): + language: Required[str] name: Required[str] version: Required[str] - hash: NotRequired[str] + hash: Required[Optional[str]] - def __init__(self, params: ResourceParameters, **kwargs: Unpack[PackageAttributes]): - super().__init__(params, **kwargs) + def __init__(self, ctx, path, /, **attributes: Unpack[_Package]): + super().__init__(ctx, path, **attributes) -class Packages(Resources, Sequence[Package]): +class Packages(ActiveFinderMethods["Package"], ActiveSequence["Package"]): """A collection of packages.""" - def __init__(self, params, endpoint): - super().__init__(params) - self._endpoint = endpoint - self._packages = [] - self.reload() - - def __getitem__(self, index): - """Retrieve an item or slice from the sequence.""" - return self._packages[index] + def __init__(self, ctx, path): + super().__init__(ctx, path, "name") - def __len__(self): - """Return the length of the sequence.""" - return len(self._packages) + def _create_instance(self, path, /, **attributes): + return Package(self._ctx, path, **attributes) - def __repr__(self): - """Return the string representation of the sequence.""" - return f"Packages({', '.join(map(str, self._packages))})" + class _FindBy(TypedDict, total=False): + language: NotRequired[str] + name: NotRequired[str] + version: NotRequired[str] + hash: NotRequired[Optional[str]] - def count(self, value): - """Return the number of occurrences of a value in the sequence.""" - return self._packages.count(value) + @overload + def find_by(self, **conditions: Unpack[_FindBy]): + ... - def index(self, value, start=0, stop=None): - """Return the index of the first occurrence of a value in the sequence.""" - if stop is None: - stop = len(self._packages) - return self._packages.index(value, start, stop) + @overload + def find_by(self, **conditions): + ... - def reload(self) -> "Packages": - """Reload packages from the Connect server. - - Returns - ------- - List[Package] - """ - response = self.params.session.get(self._endpoint) - results = response.json() - packages = [Package(self.params, **result) for result in results] - self._packages = packages - return self + def find_by(self, **conditions): + return super().find_by(**conditions) +class PackagesMixin(Active): + """Mixin class to add a packages attribute.""" -class PackagesMixin(Resource): - """Mixin class to add a packages to a resource.""" + def __init__(self, ctx, path, /, **attributes): + """Mixin class which adds a `packages` attribute. - class HasGuid(TypedDict): - """Has a guid.""" - - guid: Required[str] - - def __init__(self, params: ResourceParameters, **kwargs: Unpack[HasGuid]): - super().__init__(params, **kwargs) - self._guid = kwargs["guid"] - self._packages: Optional[Packages] = None - - @property - def packages(self) -> Packages: - """Get the packages.""" - if self._packages: - return self._packages + Parameters + ---------- + ctx : Context + The context object containing the session and URL for API interactions + path : str + The HTTP path component for the resource endpoint + **attributes : dict + Resource attributes passed + """ + super().__init__(ctx, path, **attributes) - endpoint = self.params.url + f"v1/content/{self._guid}/packages" - self._packages = Packages(self.params, endpoint) - return self._packages + path = posixpath.join(path, "packages") + self.packages = Packages(ctx, path) diff --git a/src/posit/connect/resources.py b/src/posit/connect/resources.py index ed0de84e..24df3240 100644 --- a/src/posit/connect/resources.py +++ b/src/posit/connect/resources.py @@ -1,6 +1,7 @@ import posixpath import warnings from abc import ABC, abstractmethod +from copy import copy from dataclasses import dataclass from typing import Any, Generic, List, Optional, Sequence, TypeVar, overload @@ -81,7 +82,7 @@ class ActiveSequence(ABC, Generic[T], Sequence[T]): _cache: Optional[List[T]] - def __init__(self, ctx: Context, path: str, uid: str = "guid"): + def __init__(self, ctx: Context, path: str, uid: str = "guid", params: dict = {}): """A sequence abstraction for any HTTP GET endpoint that returns a collection. Parameters @@ -97,6 +98,7 @@ def __init__(self, ctx: Context, path: str, uid: str = "guid"): self._ctx = ctx self._path = path self._uid = uid + self._params = params self._cache = None @abstractmethod @@ -114,7 +116,7 @@ def fetch(self) -> List[T]: List[T] """ endpoint = self._ctx.url + self._path - response = self._ctx.session.get(endpoint) + response = self._ctx.session.get(endpoint, params=self._params) results = response.json() return [self._to_instance(result) for result in results] diff --git a/tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/packages.json b/tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/packages.json new file mode 100644 index 00000000..c9882816 --- /dev/null +++ b/tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/packages.json @@ -0,0 +1,7 @@ +[ + { + "language": "python", + "name": "posit", + "version": "0.6.0" + } +] diff --git a/tests/posit/connect/__api__/v1/packages.json b/tests/posit/connect/__api__/v1/packages.json new file mode 100644 index 00000000..a259eef1 --- /dev/null +++ b/tests/posit/connect/__api__/v1/packages.json @@ -0,0 +1,11 @@ +{ + "results": [ + { + "language": "python", + "name": "posit", + "version": "0.6.0" + } + ], + "current_page": 1, + "total": 1 +} diff --git a/tests/posit/connect/test_packages.py b/tests/posit/connect/test_packages.py index 8b64a9e1..3f0343e4 100644 --- a/tests/posit/connect/test_packages.py +++ b/tests/posit/connect/test_packages.py @@ -1,128 +1,59 @@ -import requests import responses -from posit.connect.packages import PackagesMixin -from posit.connect.resources import ResourceParameters -from posit.connect.urls import Url +from posit.connect.client import Client +from .api import load_mock # type: ignore -class TestPackagesMixin: - def setup_method(self): - self.url = Url("http://connect.example/__api__") - self.endpoint = self.url + "v1/content/1/packages" - self.session = requests.Session() - self.params = ResourceParameters(self.session, self.url) - self.mixin = PackagesMixin(self.params, guid="1") +class TestPackagesMixin: @responses.activate - def test_packages(self): - # mock - mock_get = responses.get( - self.endpoint, - json=[ - { - "language": "python", - "name": "posit-sdk", - "version": "0.5.1.dev3+gd4bba40.d20241016", - } - ], + def test(self): + responses.get( + "https://connect.example/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066", + json=load_mock("v1/content/f2f37341-e21d-3d80-c698-a935ad614066.json"), ) - # call - packages = self.mixin.packages + responses.get( + "https://connect.example/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/packages", + json=load_mock("v1/content/f2f37341-e21d-3d80-c698-a935ad614066/packages.json"), + ) - # assert - assert mock_get.call_count == 1 - assert packages[0] == { - "language": "python", - "name": "posit-sdk", - "version": "0.5.1.dev3+gd4bba40.d20241016", - } + c = Client("https://connect.example", "12345") + content = c.content.get("f2f37341-e21d-3d80-c698-a935ad614066") + assert len(content.packages) == 1 + + +class TestPackagesFind: + @responses.activate @responses.activate - def test_packages_are_cached(self): - # mock + def test(self): mock_get = responses.get( - self.endpoint, - json=[ - { - "language": "python", - "name": "posit-sdk", - "version": "0.5.1.dev3+gd4bba40.d20241016", - } - ], + "https://connect.example/__api__/v1/packages", + json=load_mock("v1/packages.json"), ) - # call attribute twice, the second call should be cached - self.mixin.packages - self.mixin.packages + c = Client("https://connect.example", "12345") + c.ctx.version = None - # assert called once + package = c.packages.find("posit") + assert package + assert package["name"] == "posit" assert mock_get.call_count == 1 - @responses.activate - def test_packages_count(self): - responses.get( - self.endpoint, - json=[ - { - "language": "python", - "name": "posit-sdk", - "version": "0.5.1.dev3+gd4bba40.d20241016", - } - ], - ) - - packages = self.mixin.packages - count = packages.count( - { - "language": "python", - "name": "posit-sdk", - "version": "0.5.1.dev3+gd4bba40.d20241016", - } - ) - - assert count == 1 +class TestPackagesFindBy: @responses.activate - def test_packages_index(self): - responses.get( - self.endpoint, - json=[ - { - "language": "python", - "name": "posit-sdk", - "version": "0.5.1.dev3+gd4bba40.d20241016", - } - ], - ) - - packages = self.mixin.packages - index = packages.index( - { - "language": "python", - "name": "posit-sdk", - "version": "0.5.1.dev3+gd4bba40.d20241016", - } + def test(self): + mock_get = responses.get( + "https://connect.example/__api__/v1/packages", + json=load_mock("v1/packages.json"), ) - assert index == 0 + c = Client("https://connect.example", "12345") + c.ctx.version = None - @responses.activate - def test_packages_repr(self): - responses.get( - self.endpoint, - json=[ - { - "language": "python", - "name": "posit-sdk", - "version": "0.5.1.dev3+gd4bba40.d20241016", - } - ], - ) - - packages = self.mixin.packages - assert ( - repr(packages) - == "Packages({'language': 'python', 'name': 'posit-sdk', 'version': '0.5.1.dev3+gd4bba40.d20241016'})" - ) + package = c.packages.find_by(name="posit") + assert package + assert package["name"] == "posit" + assert mock_get.call_count == 1 From a4bc5365ebf69b8c273b14008e6f6f2b0dbb8a3e Mon Sep 17 00:00:00 2001 From: tdstein Date: Thu, 7 Nov 2024 10:33:20 -0500 Subject: [PATCH 04/13] --wip-- [skip ci] --- src/posit/connect/client.py | 10 +- src/posit/connect/content.py | 2 +- src/posit/connect/context.py | 4 +- src/posit/connect/packages.py | 151 +++++++++++++++--- src/posit/connect/resources.py | 12 +- .../posit/connect/external/test_databricks.py | 4 +- .../posit/connect/external/test_snowflake.py | 4 +- .../posit/connect/oauth/test_associations.py | 8 +- .../posit/connect/oauth/test_integrations.py | 10 +- tests/posit/connect/oauth/test_oauth.py | 2 +- tests/posit/connect/oauth/test_sessions.py | 8 +- tests/posit/connect/test_client.py | 2 +- tests/posit/connect/test_context.py | 6 +- tests/posit/connect/test_packages.py | 18 +-- 14 files changed, 171 insertions(+), 70 deletions(-) diff --git a/src/posit/connect/client.py b/src/posit/connect/client.py index ba34aabe..2813a728 100644 --- a/src/posit/connect/client.py +++ b/src/posit/connect/client.py @@ -14,7 +14,7 @@ from .groups import Groups from .metrics import Metrics from .oauth import OAuth -from .packages import Packages +from .packages import GlobalPackages from .resources import ResourceParameters from .tasks import Tasks from .users import User, Users @@ -156,7 +156,7 @@ def __init__(self, *args, **kwargs) -> None: session.hooks["response"].append(hooks.handle_errors) self.session = session self.resource_params = ResourceParameters(session, self.cfg.url) - self.ctx = Context(self.session, self.cfg.url) + self._ctx = Context(self.session, self.cfg.url) @property def version(self) -> Optional[str]: @@ -168,7 +168,7 @@ def version(self) -> Optional[str]: str The version of the Posit Connect server. """ - return self.ctx.version + return self._ctx.version @property def me(self) -> User: @@ -271,8 +271,8 @@ def oauth(self) -> OAuth: return OAuth(self.resource_params, self.cfg.api_key) @property - def packages(self) -> Packages: - return Packages(self.ctx, "v1/packages") + def packages(self) -> GlobalPackages: + return GlobalPackages(self._ctx, "v1/packages") @property def vanities(self) -> Vanities: diff --git a/src/posit/connect/content.py b/src/posit/connect/content.py index 750cd8e5..536522a9 100644 --- a/src/posit/connect/content.py +++ b/src/posit/connect/content.py @@ -13,7 +13,7 @@ from .env import EnvVars from .jobs import JobsMixin from .oauth.associations import ContentItemAssociations -from .packages import PackagesMixin +from .packages.packages import PackagesMixin from .permissions import Permissions from .resources import Resource, ResourceParameters, Resources from .vanities import VanityMixin diff --git a/src/posit/connect/context.py b/src/posit/connect/context.py index f8ef13b2..1f312322 100644 --- a/src/posit/connect/context.py +++ b/src/posit/connect/context.py @@ -11,7 +11,7 @@ def requires(version: str): def decorator(func): @functools.wraps(func) def wrapper(instance: ContextManager, *args, **kwargs): - ctx = instance.ctx + ctx = instance._ctx if ctx.version and Version(ctx.version) < Version(version): raise RuntimeError( f"This API is not available in Connect version {ctx.version}. Please upgrade to version {version} or later.", @@ -45,4 +45,4 @@ def version(self, value): class ContextManager(Protocol): - ctx: Context + _ctx: Context diff --git a/src/posit/connect/packages.py b/src/posit/connect/packages.py index 1328c5ad..087ee753 100644 --- a/src/posit/connect/packages.py +++ b/src/posit/connect/packages.py @@ -1,64 +1,171 @@ import posixpath -from typing import Optional, TypedDict, overload +from typing import Literal, Optional, TypedDict, overload from typing_extensions import NotRequired, Required, Unpack +from posit.connect.context import requires +from posit.connect.paginator import Paginator + from .resources import Active, ActiveFinderMethods, ActiveSequence -class Package(Active): +class ContentPackage(Active): class _Package(TypedDict): language: Required[str] name: Required[str] version: Required[str] hash: Required[Optional[str]] - def __init__(self, ctx, path, /, **attributes: Unpack[_Package]): - super().__init__(ctx, path, **attributes) + def __init__(self, ctx, /, **attributes: Unpack[_Package]): + # todo - passing "" is a hack since path isn't needed. Instead, this class should inherit from Resource, but ActiveSequence is designed to operate on Active. That should change. + super().__init__(ctx, "", **attributes) -class Packages(ActiveFinderMethods["Package"], ActiveSequence["Package"]): +class ContentPackages(ActiveFinderMethods["ContentPackage"], ActiveSequence["ContentPackage"]): """A collection of packages.""" def __init__(self, ctx, path): super().__init__(ctx, path, "name") def _create_instance(self, path, /, **attributes): - return Package(self._ctx, path, **attributes) + return ContentPackage(self._ctx, **attributes) + + def find(self, uid): + raise NotImplementedError("The 'find' method is not support by the Packages API.") class _FindBy(TypedDict, total=False): - language: NotRequired[str] + language: NotRequired[Literal["python", "r"]] + """Programming language ecosystem, options are 'python' and 'r'""" + name: NotRequired[str] + """The package name""" + version: NotRequired[str] + """The package version""" + hash: NotRequired[Optional[str]] + """Package description hash for R packages.""" @overload def find_by(self, **conditions: Unpack[_FindBy]): - ... + """ + Find the first record matching the specified conditions. + + There is no implied ordering, so if order matters, you should specify it yourself. + + Parameters + ---------- + **conditions : Unpack[_FindBy] + Conditions for filtering packages. The following keys are accepted: + + language : {"python", "r"}, not required + Programming language ecosystem, options are 'python' and 'r' + + name : str, not required + The package name + + version : str, not required + The package version + + hash : str or None, optional, not required + Package description hash for R packages. + + Returns + ------- + Optional[T] + The first record matching the specified conditions, or `None` if no such record exists. + """ @overload - def find_by(self, **conditions): - ... + def find_by(self, **conditions): ... def find_by(self, **conditions): return super().find_by(**conditions) -class PackagesMixin(Active): + +class ContentPackagesMixin(Active): """Mixin class to add a packages attribute.""" - def __init__(self, ctx, path, /, **attributes): - """Mixin class which adds a `packages` attribute. + @property + @requires(version="2024.11.0") + def packages(self): + path = posixpath.join(self._path, "packages") + return ContentPackages(self._ctx, path) + + +class GlobalPackage(Active): + class _GlobalPackage(TypedDict): + language: Required[str] + name: Required[str] + version: Required[str] + hash: Required[Optional[str]] + + def __init__(self, ctx, /, **attributes: Unpack[_GlobalPackage]): + # todo - passing "" is a hack since path isn't needed. Instead, this class should inherit from Resource, but ActiveSequence is designed to operate on Active. That should change. + super().__init__(ctx, "", **attributes) + + +class GlobalPackages(ContentPackages): + def __init__(self, ctx, path): + super().__init__(ctx, path, "name") + + def _create_instance(self, path, /, **attributes): + return ContentPackage(self._ctx, **attributes) + + def find(self, uid): + raise NotImplementedError("The 'find' method is not support by the Packages API.") + + class _FindBy(TypedDict, total=False): + language: NotRequired[Literal["python", "r"]] + """Programming language ecosystem, options are 'python' and 'r'""" + + name: NotRequired[str] + """The package name""" + + version: NotRequired[str] + """The package version""" + + hash: NotRequired[Optional[str]] + """Package description hash for R packages.""" + + def fetch(self, **conditions): + url = self._ctx.url + self._path + paginator = Paginator(self._ctx.session, url, conditions) + results = paginator.fetch_results() + return [self._create_instance("", **result) for result in results] + + @overload + def find_by(self, **conditions: Unpack[_FindBy]): + """ + Find the first record matching the specified conditions. + + There is no implied ordering, so if order matters, you should specify it yourself. Parameters ---------- - ctx : Context - The context object containing the session and URL for API interactions - path : str - The HTTP path component for the resource endpoint - **attributes : dict - Resource attributes passed + **conditions : Unpack[_FindBy] + Conditions for filtering packages. The following keys are accepted: + + language : {"python", "r"}, not required + Programming language ecosystem, options are 'python' and 'r' + + name : str, not required + The package name + + version : str, not required + The package version + + hash : str or None, optional, not required + Package description hash for R packages. + + Returns + ------- + Optional[T] + The first record matching the specified conditions, or `None` if no such record exists. """ - super().__init__(ctx, path, **attributes) - path = posixpath.join(path, "packages") - self.packages = Packages(ctx, path) + @overload + def find_by(self, **conditions): ... + + def find_by(self, **conditions): + return super().find_by(**conditions) diff --git a/src/posit/connect/resources.py b/src/posit/connect/resources.py index 24df3240..02e6af9d 100644 --- a/src/posit/connect/resources.py +++ b/src/posit/connect/resources.py @@ -1,7 +1,8 @@ +from __future__ import annotations + import posixpath import warnings from abc import ABC, abstractmethod -from copy import copy from dataclasses import dataclass from typing import Any, Generic, List, Optional, Sequence, TypeVar, overload @@ -82,7 +83,7 @@ class ActiveSequence(ABC, Generic[T], Sequence[T]): _cache: Optional[List[T]] - def __init__(self, ctx: Context, path: str, uid: str = "guid", params: dict = {}): + def __init__(self, ctx: Context, path: str, uid: str = "guid"): """A sequence abstraction for any HTTP GET endpoint that returns a collection. Parameters @@ -98,7 +99,6 @@ def __init__(self, ctx: Context, path: str, uid: str = "guid", params: dict = {} self._ctx = ctx self._path = path self._uid = uid - self._params = params self._cache = None @abstractmethod @@ -106,7 +106,7 @@ def _create_instance(self, path: str, /, **kwargs: Any) -> T: """Create an instance of 'T'.""" raise NotImplementedError() - def fetch(self) -> List[T]: + def fetch(self, **conditions) -> List[T]: """Fetch the collection. Fetches the collection directly from Connect. This operation does not effect the cache state. @@ -116,7 +116,7 @@ def fetch(self) -> List[T]: List[T] """ endpoint = self._ctx.url + self._path - response = self._ctx.session.get(endpoint, params=self._params) + response = self._ctx.session.get(endpoint, params=conditions) results = response.json() return [self._to_instance(result) for result in results] @@ -203,7 +203,7 @@ def find(self, uid) -> T: result = response.json() return self._to_instance(result) - def find_by(self, **conditions: Any) -> Optional[T]: + def find_by(self, **conditions) -> Optional[T]: """ Find the first record matching the specified conditions. diff --git a/tests/posit/connect/external/test_databricks.py b/tests/posit/connect/external/test_databricks.py index 777ff757..0f02a58e 100644 --- a/tests/posit/connect/external/test_databricks.py +++ b/tests/posit/connect/external/test_databricks.py @@ -48,7 +48,7 @@ def test_posit_credentials_provider(self): register_mocks() client = Client(api_key="12345", url="https://connect.example/") - client.ctx.version = None + client._ctx.version = None cp = PositCredentialsProvider(client=client, user_session_token="cit") assert cp() == {"Authorization": "Bearer dynamic-viewer-access-token"} @@ -58,7 +58,7 @@ def test_posit_credentials_strategy(self): register_mocks() client = Client(api_key="12345", url="https://connect.example/") - client.ctx.version = None + client._ctx.version = None cs = PositCredentialsStrategy( local_strategy=mock_strategy(), user_session_token="cit", diff --git a/tests/posit/connect/external/test_snowflake.py b/tests/posit/connect/external/test_snowflake.py index 065e8ad3..3d2c5414 100644 --- a/tests/posit/connect/external/test_snowflake.py +++ b/tests/posit/connect/external/test_snowflake.py @@ -33,7 +33,7 @@ def test_posit_authenticator(self): register_mocks() client = Client(api_key="12345", url="https://connect.example/") - client.ctx.version = None + client._ctx.version = None auth = PositAuthenticator( local_authenticator="SNOWFLAKE", user_session_token="cit", @@ -45,7 +45,7 @@ def test_posit_authenticator(self): def test_posit_authenticator_fallback(self): # local_authenticator is used when the content is running locally client = Client(api_key="12345", url="https://connect.example/") - client.ctx.version = None + client._ctx.version = None auth = PositAuthenticator( local_authenticator="SNOWFLAKE", user_session_token="cit", diff --git a/tests/posit/connect/oauth/test_associations.py b/tests/posit/connect/oauth/test_associations.py index 7ea13a70..37ae98ad 100644 --- a/tests/posit/connect/oauth/test_associations.py +++ b/tests/posit/connect/oauth/test_associations.py @@ -55,7 +55,7 @@ def test(self): # setup c = Client("https://connect.example", "12345") - c.ctx.version = None + c._ctx.version = None # invoke associations = c.oauth.integrations.get(guid).associations.find() @@ -84,7 +84,7 @@ def test(self): # setup c = Client("https://connect.example", "12345") - c.ctx.version = None + c._ctx.version = None # invoke associations = c.content.get(guid).oauth.associations.find() @@ -117,7 +117,7 @@ def test(self): # setup c = Client("https://connect.example", "12345") - c.ctx.version = None + c._ctx.version = None # invoke c.content.get(guid).oauth.associations.update(new_integration_guid) @@ -145,7 +145,7 @@ def test(self): # setup c = Client("https://connect.example", "12345") - c.ctx.version = None + c._ctx.version = None # invoke c.content.get(guid).oauth.associations.delete() diff --git a/tests/posit/connect/oauth/test_integrations.py b/tests/posit/connect/oauth/test_integrations.py index a2a6d223..d61b20b3 100644 --- a/tests/posit/connect/oauth/test_integrations.py +++ b/tests/posit/connect/oauth/test_integrations.py @@ -23,7 +23,7 @@ def test(self): # setup c = Client("https://connect.example", "12345") - c.ctx.version = None + c._ctx.version = None integration = c.oauth.integrations.get(guid) # invoke @@ -44,7 +44,7 @@ def test(self): ) c = Client("https://connect.example", "12345") - c.ctx.version = None + c._ctx.version = None integration = c.oauth.integrations.get(guid) assert integration["guid"] == guid @@ -89,7 +89,7 @@ def test(self): # setup c = Client("https://connect.example", "12345") - c.ctx.version = None + c._ctx.version = None # invoke integration = c.oauth.integrations.create( @@ -118,7 +118,7 @@ def test(self): # setup c = Client("https://connect.example", "12345") - c.ctx.version = None + c._ctx.version = None # invoke integrations = c.oauth.integrations.find() @@ -143,7 +143,7 @@ def test(self): # setup c = Client("https://connect.example", "12345") - c.ctx.version = None + c._ctx.version = None integration = c.oauth.integrations.get(guid) assert mock_get.call_count == 1 diff --git a/tests/posit/connect/oauth/test_oauth.py b/tests/posit/connect/oauth/test_oauth.py index fce2f42b..5b7e5b2f 100644 --- a/tests/posit/connect/oauth/test_oauth.py +++ b/tests/posit/connect/oauth/test_oauth.py @@ -24,5 +24,5 @@ def test_get_credentials(self): }, ) c = Client(api_key="12345", url="https://connect.example/") - c.ctx.version = None + c._ctx.version = None assert c.oauth.get_credentials("cit")["access_token"] == "viewer-token" diff --git a/tests/posit/connect/oauth/test_sessions.py b/tests/posit/connect/oauth/test_sessions.py index 9af1656e..a897f24c 100644 --- a/tests/posit/connect/oauth/test_sessions.py +++ b/tests/posit/connect/oauth/test_sessions.py @@ -21,7 +21,7 @@ def test(self): # setup c = Client("https://connect.example", "12345") - c.ctx.version = None + c._ctx.version = None session = c.oauth.sessions.get(guid) # invoke @@ -42,7 +42,7 @@ def test(self): # setup c = Client("https://connect.example", "12345") - c.ctx.version = None + c._ctx.version = None # invoke sessions = c.oauth.sessions.find() @@ -65,7 +65,7 @@ def test_params_all(self): # setup c = Client("https://connect.example", "12345") - c.ctx.version = None + c._ctx.version = None # invoke c.oauth.sessions.find(all=True) @@ -87,7 +87,7 @@ def test(self): # setup c = Client("https://connect.example", "12345") - c.ctx.version = None + c._ctx.version = None # invoke session = c.oauth.sessions.get(guid=guid) diff --git a/tests/posit/connect/test_client.py b/tests/posit/connect/test_client.py index 5425a301..a8c2ab6b 100644 --- a/tests/posit/connect/test_client.py +++ b/tests/posit/connect/test_client.py @@ -180,7 +180,7 @@ def test_required_version(self): api_key = "12345" url = "https://connect.example.com" client = Client(api_key=api_key, url=url) - client.ctx.version = "2024.07.0" + client._ctx.version = "2024.07.0" with pytest.raises(RuntimeError): client.oauth # noqa: B018 diff --git a/tests/posit/connect/test_context.py b/tests/posit/connect/test_context.py index 3cb4b87b..be0330e4 100644 --- a/tests/posit/connect/test_context.py +++ b/tests/posit/connect/test_context.py @@ -13,7 +13,7 @@ class TestRequires: def test_version_unsupported(self): class Stub(ContentManager): def __init__(self, ctx): - self.ctx = ctx + self._ctx = ctx @requires("1.0.0") def fail(self): @@ -29,7 +29,7 @@ def fail(self): def test_version_supported(self): class Stub(ContentManager): def __init__(self, ctx): - self.ctx = ctx + self._ctx = ctx @requires("1.0.0") def success(self): @@ -44,7 +44,7 @@ def success(self): def test_version_missing(self): class Stub(ContentManager): def __init__(self, ctx): - self.ctx = ctx + self._ctx = ctx @requires("1.0.0") def success(self): diff --git a/tests/posit/connect/test_packages.py b/tests/posit/connect/test_packages.py index 3f0343e4..4d42f535 100644 --- a/tests/posit/connect/test_packages.py +++ b/tests/posit/connect/test_packages.py @@ -1,3 +1,4 @@ +import pytest import responses from posit.connect.client import Client @@ -21,25 +22,18 @@ def test(self): c = Client("https://connect.example", "12345") content = c.content.get("f2f37341-e21d-3d80-c698-a935ad614066") + content._ctx.version = None assert len(content.packages) == 1 class TestPackagesFind: - @responses.activate @responses.activate def test(self): - mock_get = responses.get( - "https://connect.example/__api__/v1/packages", - json=load_mock("v1/packages.json"), - ) - c = Client("https://connect.example", "12345") - c.ctx.version = None + c._ctx.version = None - package = c.packages.find("posit") - assert package - assert package["name"] == "posit" - assert mock_get.call_count == 1 + with pytest.raises(NotImplementedError): + c.packages.find("posit") class TestPackagesFindBy: @@ -51,7 +45,7 @@ def test(self): ) c = Client("https://connect.example", "12345") - c.ctx.version = None + c._ctx.version = None package = c.packages.find_by(name="posit") assert package From 0e8e34acad44ff8a87b3ef56c1069006b7d4062a Mon Sep 17 00:00:00 2001 From: tdstein Date: Thu, 7 Nov 2024 14:27:57 -0500 Subject: [PATCH 05/13] feat: adds packages --- integration/Makefile | 93 ++++++++++--------- integration/tests/posit/connect/__init__.py | 1 + .../tests/posit/connect/test_packages.py | 41 ++++++++ src/posit/connect/client.py | 7 +- src/posit/connect/content.py | 2 +- src/posit/connect/packages.py | 71 ++++++++++---- src/posit/connect/resources.py | 10 +- 7 files changed, 154 insertions(+), 71 deletions(-) create mode 100644 integration/tests/posit/connect/test_packages.py diff --git a/integration/Makefile b/integration/Makefile index 65df52a3..c89178d4 100644 --- a/integration/Makefile +++ b/integration/Makefile @@ -11,8 +11,8 @@ CONNECT_BOOTSTRAP_SECRETKEY ?= $(shell head -c 32 /dev/random | base64) .DEFAULT_GOAL := latest .PHONY: $(CONNECT_VERSIONS) \ - all \ - build \ + all \ + build \ down \ down-% \ latest \ @@ -22,8 +22,9 @@ CONNECT_BOOTSTRAP_SECRETKEY ?= $(shell head -c 32 /dev/random | base64) help # Versions -CONNECT_VERSIONS := 2024.08.0 \ - 2024.06.0 \ +CONNECT_VERSIONS := 2024.09.0 \ + 2024.08.0 \ + 2024.06.0 \ 2024.05.0 \ 2024.04.1 \ 2024.04.0 \ @@ -42,8 +43,8 @@ CONNECT_VERSIONS := 2024.08.0 \ 2022.11.0 clean: - rm -rf logs reports - find . -type d -empty -delete + rm -rf logs reports + find . -type d -empty -delete # Run test suite for a specific Connect version. # @@ -60,19 +61,19 @@ all: $(CONNECT_VERSIONS:%=%) preview # Run test suite against latest Connect version. latest: - $(MAKE) $(firstword $(CONNECT_VERSIONS)) + $(MAKE) $(firstword $(CONNECT_VERSIONS)) # Run test suite against preview Connect version. preview: - $(MAKE) \ - DOCKER_CONNECT_IMAGE=rstudio/rstudio-connect-preview \ - DOCKER_CONNECT_IMAGE_TAG=dev-jammy-daily \ - down-preview up-preview + $(MAKE) \ + DOCKER_CONNECT_IMAGE=rstudio/rstudio-connect-preview \ + DOCKER_CONNECT_IMAGE_TAG=dev-jammy-daily \ + down-preview up-preview # Build Dockerfile build: - make -C .. $(UV_LOCK) - docker build -t $(DOCKER_PROJECT_IMAGE_TAG) .. + make -C .. $(UV_LOCK) + docker build -t $(DOCKER_PROJECT_IMAGE_TAG) .. # Tear down resources. # @@ -86,12 +87,12 @@ down: $(CONNECT_VERSIONS:%=down-%) down-%: DOCKER_CONNECT_IMAGE_TAG=jammy-$* down-%: CONNECT_VERSION=$* down-%: - CONNECT_BOOTSTRAP_SECRETKEY=$(CONNECT_BOOTSTRAP_SECRETKEY) \ - DOCKER_CONNECT_IMAGE=$(DOCKER_CONNECT_IMAGE) \ - DOCKER_CONNECT_IMAGE_TAG=$(DOCKER_CONNECT_IMAGE_TAG) \ - CONNECT_VERSION=$* \ - DOCKER_PROJECT_IMAGE_TAG=$(DOCKER_PROJECT_IMAGE_TAG) \ - $(DOCKER_COMPOSE) -p $(PROJECT_NAME)-$(subst .,-,$(CONNECT_VERSION)) down -v + CONNECT_BOOTSTRAP_SECRETKEY=$(CONNECT_BOOTSTRAP_SECRETKEY) \ + DOCKER_CONNECT_IMAGE=$(DOCKER_CONNECT_IMAGE) \ + DOCKER_CONNECT_IMAGE_TAG=$(DOCKER_CONNECT_IMAGE_TAG) \ + CONNECT_VERSION=$* \ + DOCKER_PROJECT_IMAGE_TAG=$(DOCKER_PROJECT_IMAGE_TAG) \ + $(DOCKER_COMPOSE) -p $(PROJECT_NAME)-$(subst .,-,$(CONNECT_VERSION)) down -v # Create, start, and run Docker Compose. # @@ -103,39 +104,39 @@ up: $(CONNECT_VERSIONS:%=up-%) up-%: CONNECT_VERSION=$* up-%: DOCKER_CONNECT_IMAGE_TAG=jammy-$* up-%: build - CONNECT_BOOTSTRAP_SECRETKEY=$(CONNECT_BOOTSTRAP_SECRETKEY) \ - DOCKER_CONNECT_IMAGE=$(DOCKER_CONNECT_IMAGE) \ - DOCKER_CONNECT_IMAGE_TAG=$(DOCKER_CONNECT_IMAGE_TAG) \ - CONNECT_VERSION=$* \ - DOCKER_PROJECT_IMAGE_TAG=$(DOCKER_PROJECT_IMAGE_TAG) \ - $(DOCKER_COMPOSE) -p $(PROJECT_NAME)-$(subst .,-,$(CONNECT_VERSION)) up -V --abort-on-container-exit --no-build + CONNECT_BOOTSTRAP_SECRETKEY=$(CONNECT_BOOTSTRAP_SECRETKEY) \ + DOCKER_CONNECT_IMAGE=$(DOCKER_CONNECT_IMAGE) \ + DOCKER_CONNECT_IMAGE_TAG=$(DOCKER_CONNECT_IMAGE_TAG) \ + CONNECT_VERSION=$* \ + DOCKER_PROJECT_IMAGE_TAG=$(DOCKER_PROJECT_IMAGE_TAG) \ + $(DOCKER_COMPOSE) -p $(PROJECT_NAME)-$(subst .,-,$(CONNECT_VERSION)) up -V --abort-on-container-exit --no-build # Show help message. help: - @echo "Makefile Targets:" - @echo " all (default) Run test suite for all Connect versions." - @echo " latest Run test suite for latest Connect version." - @echo " preview Run test suite for preview Connect version." - @echo " Run test suite for the specified Connect version. (e.g., make 2024.05.0)" - @echo " up Start Docker Compose for all Connect version." - @echo " down Tear down Docker resources for all Connect versions." - @echo " clean Clean up the project directory." - @echo " help Show this help message." - @echo - @echo "Common Usage:" - @echo " make -j 4 Run test suite in parallel for all Connect versions." - @echo " make latest Run test suite for latest Connect version." - @echo " make preview Run test suite for preview Connect version." - @echo " make 2024.05.0 Run test suite for specific Connect version." + @echo "Makefile Targets:" + @echo " all (default) Run test suite for all Connect versions." + @echo " latest Run test suite for latest Connect version." + @echo " preview Run test suite for preview Connect version." + @echo " Run test suite for the specified Connect version. (e.g., make 2024.05.0)" + @echo " up Start Docker Compose for all Connect version." + @echo " down Tear down Docker resources for all Connect versions." + @echo " clean Clean up the project directory." + @echo " help Show this help message." + @echo + @echo "Common Usage:" + @echo " make -j 4 Run test suite in parallel for all Connect versions." + @echo " make latest Run test suite for latest Connect version." + @echo " make preview Run test suite for preview Connect version." + @echo " make 2024.05.0 Run test suite for specific Connect version." # Run tests. # # Typically call from docker-compose.yaml. Assumes Connect server is running # on local network. test: - mkdir -p logs - set -o pipefail; \ - CONNECT_VERSION=${CONNECT_VERSION} \ - CONNECT_API_KEY="$(shell $(UV) run rsconnect bootstrap -i -s http://connect:3939 --raw)" \ - $(UV) run pytest -s -k TestJobs --junit-xml=./reports/$(CONNECT_VERSION).xml | \ - tee ./logs/$(CONNECT_VERSION).log; + mkdir -p logs + set -o pipefail; \ + CONNECT_VERSION=${CONNECT_VERSION} \ + CONNECT_API_KEY="$(shell $(UV) run rsconnect bootstrap -i -s http://connect:3939 --raw)" \ + $(UV) run pytest -s --junit-xml=./reports/$(CONNECT_VERSION).xml | \ + tee ./logs/$(CONNECT_VERSION).log; diff --git a/integration/tests/posit/connect/__init__.py b/integration/tests/posit/connect/__init__.py index 7d8dd0b3..8fcd91f1 100644 --- a/integration/tests/posit/connect/__init__.py +++ b/integration/tests/posit/connect/__init__.py @@ -4,3 +4,4 @@ client = connect.Client() CONNECT_VERSION = version.parse(client.version) +print(CONNECT_VERSION) diff --git a/integration/tests/posit/connect/test_packages.py b/integration/tests/posit/connect/test_packages.py new file mode 100644 index 00000000..02ae5a96 --- /dev/null +++ b/integration/tests/posit/connect/test_packages.py @@ -0,0 +1,41 @@ +from pathlib import Path + +import pytest +from packaging import version + +from posit import connect + +from . import CONNECT_VERSION + + +@pytest.mark.skipif( + CONNECT_VERSION <= version.parse("2024.09.0"), + reason="Packages API unavailable", +) +class TestPackages: + @classmethod + def setup_class(cls): + cls.client = connect.Client() + cls.content = cls.client.content.create(name=cls.__name__) + path = Path("../../../resources/connect/bundles/example-flask-minimal/bundle.tar.gz") + path = (Path(__file__).parent / path).resolve() + bundle = cls.content.bundles.create(str(path)) + task = bundle.deploy() + task.wait_for() + + @classmethod + def teardown_class(cls): + cls.content.delete() + + def test(self): + # assert self.client.packages + assert self.content.packages + + def test_find_by(self): + # package = self.client.packages.find_by(name="flask") + # assert package + # assert package["name"] == "flask" + + package = self.content.packages.find_by(name="flask") + assert package + assert package["name"] == "flask" diff --git a/src/posit/connect/client.py b/src/posit/connect/client.py index 2813a728..8f0c45b3 100644 --- a/src/posit/connect/client.py +++ b/src/posit/connect/client.py @@ -14,7 +14,7 @@ from .groups import Groups from .metrics import Metrics from .oauth import OAuth -from .packages import GlobalPackages +from .packages import Packages from .resources import ResourceParameters from .tasks import Tasks from .users import User, Users @@ -271,8 +271,9 @@ def oauth(self) -> OAuth: return OAuth(self.resource_params, self.cfg.api_key) @property - def packages(self) -> GlobalPackages: - return GlobalPackages(self._ctx, "v1/packages") + @requires(version="2024.10.0-dev") + def packages(self) -> Packages: + return Packages(self._ctx, "v1/packages") @property def vanities(self) -> Vanities: diff --git a/src/posit/connect/content.py b/src/posit/connect/content.py index 536522a9..9f3d9639 100644 --- a/src/posit/connect/content.py +++ b/src/posit/connect/content.py @@ -13,7 +13,7 @@ from .env import EnvVars from .jobs import JobsMixin from .oauth.associations import ContentItemAssociations -from .packages.packages import PackagesMixin +from .packages import ContentPackagesMixin as PackagesMixin from .permissions import Permissions from .resources import Resource, ResourceParameters, Resources from .vanities import VanityMixin diff --git a/src/posit/connect/packages.py b/src/posit/connect/packages.py index 087ee753..b770584f 100644 --- a/src/posit/connect/packages.py +++ b/src/posit/connect/packages.py @@ -1,9 +1,12 @@ +from __future__ import annotations + import posixpath -from typing import Literal, Optional, TypedDict, overload +from typing import List, Literal, Optional, TypedDict, overload from typing_extensions import NotRequired, Required, Unpack from posit.connect.context import requires +from posit.connect.errors import ClientError from posit.connect.paginator import Paginator from .resources import Active, ActiveFinderMethods, ActiveSequence @@ -30,6 +33,14 @@ def __init__(self, ctx, path): def _create_instance(self, path, /, **attributes): return ContentPackage(self._ctx, **attributes) + def fetch(self, **conditions): + try: + return super().fetch(**conditions) + except ClientError as e: + if e.http_status == 204: + return [] + raise e + def find(self, uid): raise NotImplementedError("The 'find' method is not support by the Packages API.") @@ -87,30 +98,53 @@ class ContentPackagesMixin(Active): """Mixin class to add a packages attribute.""" @property - @requires(version="2024.11.0") + @requires(version="2024.10.0-dev") def packages(self): path = posixpath.join(self._path, "packages") return ContentPackages(self._ctx, path) -class GlobalPackage(Active): - class _GlobalPackage(TypedDict): - language: Required[str] +class Package(Active): + class _Package(TypedDict): + language: Required[Literal["python", "r"]] + """Programming language ecosystem, options are 'python' and 'r'""" + name: Required[str] + """The package name""" + version: Required[str] + """The package version""" + hash: Required[Optional[str]] + """Package description hash for R packages.""" + + bundle_id: Required[str] + """The unique identifier of the bundle this package is associated with""" - def __init__(self, ctx, /, **attributes: Unpack[_GlobalPackage]): + app_id: Required[str] + """The numerical identifier of the application this package is associated with""" + + app_guid: Required[str] + """The numerical identifier of the application this package is associated with""" + + def __init__(self, ctx, /, **attributes: Unpack[_Package]): # todo - passing "" is a hack since path isn't needed. Instead, this class should inherit from Resource, but ActiveSequence is designed to operate on Active. That should change. super().__init__(ctx, "", **attributes) -class GlobalPackages(ContentPackages): +class Packages(ActiveFinderMethods["Package"], ActiveSequence["Package"]): def __init__(self, ctx, path): super().__init__(ctx, path, "name") def _create_instance(self, path, /, **attributes): - return ContentPackage(self._ctx, **attributes) + return Package(self._ctx, **attributes) + + def fetch(self, **conditions) -> List["Package"]: + # todo - add pagination support to ActiveSequence + url = self._ctx.url + self._path + paginator = Paginator(self._ctx.session, url, conditions) + results = paginator.fetch_results() + return [self._create_instance("", **result) for result in results] def find(self, uid): raise NotImplementedError("The 'find' method is not support by the Packages API.") @@ -128,14 +162,17 @@ class _FindBy(TypedDict, total=False): hash: NotRequired[Optional[str]] """Package description hash for R packages.""" - def fetch(self, **conditions): - url = self._ctx.url + self._path - paginator = Paginator(self._ctx.session, url, conditions) - results = paginator.fetch_results() - return [self._create_instance("", **result) for result in results] + bundle_id: NotRequired[str] + """The unique identifier of the bundle this package is associated with""" + + app_id: NotRequired[str] + """The numerical identifier of the application this package is associated with""" + + app_guid: NotRequired[str] + """The numerical identifier of the application this package is associated with""" @overload - def find_by(self, **conditions: Unpack[_FindBy]): + def find_by(self, **conditions: Unpack[_FindBy]) -> "Package | None": """ Find the first record matching the specified conditions. @@ -160,12 +197,12 @@ def find_by(self, **conditions: Unpack[_FindBy]): Returns ------- - Optional[T] + Optional[Package] The first record matching the specified conditions, or `None` if no such record exists. """ @overload - def find_by(self, **conditions): ... + def find_by(self, **conditions) -> "Package | None": ... - def find_by(self, **conditions): + def find_by(self, **conditions) -> "Package | None": return super().find_by(**conditions) diff --git a/src/posit/connect/resources.py b/src/posit/connect/resources.py index 02e6af9d..bb05b9dc 100644 --- a/src/posit/connect/resources.py +++ b/src/posit/connect/resources.py @@ -4,13 +4,15 @@ import warnings from abc import ABC, abstractmethod from dataclasses import dataclass -from typing import Any, Generic, List, Optional, Sequence, TypeVar, overload +from typing import TYPE_CHECKING, Any, Generic, List, Optional, Sequence, TypeVar, overload -import requests from typing_extensions import Self -from .context import Context -from .urls import Url +if TYPE_CHECKING: + import requests + + from .context import Context + from .urls import Url @dataclass(frozen=True) From 09fdb068bb430b7af29f27e3443960505c754e09 Mon Sep 17 00:00:00 2001 From: tdstein Date: Thu, 7 Nov 2024 15:36:50 -0500 Subject: [PATCH 06/13] fix makefile --- integration/Makefile | 138 +++++++++++++++++++++---------------------- 1 file changed, 69 insertions(+), 69 deletions(-) diff --git a/integration/Makefile b/integration/Makefile index c89178d4..8d54da48 100644 --- a/integration/Makefile +++ b/integration/Makefile @@ -11,40 +11,40 @@ CONNECT_BOOTSTRAP_SECRETKEY ?= $(shell head -c 32 /dev/random | base64) .DEFAULT_GOAL := latest .PHONY: $(CONNECT_VERSIONS) \ - all \ - build \ - down \ - down-% \ - latest \ - test \ - up \ - up-% \ - help + all \ + build \ + down \ + down-% \ + latest \ + test \ + up \ + up-% \ + help # Versions CONNECT_VERSIONS := 2024.09.0 \ - 2024.08.0 \ - 2024.06.0 \ - 2024.05.0 \ - 2024.04.1 \ - 2024.04.0 \ - 2024.03.0 \ - 2024.02.0 \ - 2024.01.0 \ - 2023.12.0 \ - 2023.10.0 \ - 2023.09.0 \ - 2023.07.0 \ - 2023.06.0 \ - 2023.05.0 \ - 2023.01.1 \ - 2023.01.0 \ - 2022.12.0 \ - 2022.11.0 + 2024.08.0 \ + 2024.06.0 \ + 2024.05.0 \ + 2024.04.1 \ + 2024.04.0 \ + 2024.03.0 \ + 2024.02.0 \ + 2024.01.0 \ + 2023.12.0 \ + 2023.10.0 \ + 2023.09.0 \ + 2023.07.0 \ + 2023.06.0 \ + 2023.05.0 \ + 2023.01.1 \ + 2023.01.0 \ + 2022.12.0 \ + 2022.11.0 clean: - rm -rf logs reports - find . -type d -empty -delete + rm -rf logs reports + find . -type d -empty -delete # Run test suite for a specific Connect version. # @@ -61,19 +61,19 @@ all: $(CONNECT_VERSIONS:%=%) preview # Run test suite against latest Connect version. latest: - $(MAKE) $(firstword $(CONNECT_VERSIONS)) + $(MAKE) $(firstword $(CONNECT_VERSIONS)) # Run test suite against preview Connect version. preview: - $(MAKE) \ - DOCKER_CONNECT_IMAGE=rstudio/rstudio-connect-preview \ - DOCKER_CONNECT_IMAGE_TAG=dev-jammy-daily \ - down-preview up-preview + $(MAKE) \ + DOCKER_CONNECT_IMAGE=rstudio/rstudio-connect-preview \ + DOCKER_CONNECT_IMAGE_TAG=dev-jammy-daily \ + down-preview up-preview # Build Dockerfile build: - make -C .. $(UV_LOCK) - docker build -t $(DOCKER_PROJECT_IMAGE_TAG) .. + make -C .. $(UV_LOCK) + docker build -t $(DOCKER_PROJECT_IMAGE_TAG) .. # Tear down resources. # @@ -87,12 +87,12 @@ down: $(CONNECT_VERSIONS:%=down-%) down-%: DOCKER_CONNECT_IMAGE_TAG=jammy-$* down-%: CONNECT_VERSION=$* down-%: - CONNECT_BOOTSTRAP_SECRETKEY=$(CONNECT_BOOTSTRAP_SECRETKEY) \ - DOCKER_CONNECT_IMAGE=$(DOCKER_CONNECT_IMAGE) \ - DOCKER_CONNECT_IMAGE_TAG=$(DOCKER_CONNECT_IMAGE_TAG) \ - CONNECT_VERSION=$* \ - DOCKER_PROJECT_IMAGE_TAG=$(DOCKER_PROJECT_IMAGE_TAG) \ - $(DOCKER_COMPOSE) -p $(PROJECT_NAME)-$(subst .,-,$(CONNECT_VERSION)) down -v + CONNECT_BOOTSTRAP_SECRETKEY=$(CONNECT_BOOTSTRAP_SECRETKEY) \ + DOCKER_CONNECT_IMAGE=$(DOCKER_CONNECT_IMAGE) \ + DOCKER_CONNECT_IMAGE_TAG=$(DOCKER_CONNECT_IMAGE_TAG) \ + CONNECT_VERSION=$* \ + DOCKER_PROJECT_IMAGE_TAG=$(DOCKER_PROJECT_IMAGE_TAG) \ + $(DOCKER_COMPOSE) -p $(PROJECT_NAME)-$(subst .,-,$(CONNECT_VERSION)) down -v # Create, start, and run Docker Compose. # @@ -104,39 +104,39 @@ up: $(CONNECT_VERSIONS:%=up-%) up-%: CONNECT_VERSION=$* up-%: DOCKER_CONNECT_IMAGE_TAG=jammy-$* up-%: build - CONNECT_BOOTSTRAP_SECRETKEY=$(CONNECT_BOOTSTRAP_SECRETKEY) \ - DOCKER_CONNECT_IMAGE=$(DOCKER_CONNECT_IMAGE) \ - DOCKER_CONNECT_IMAGE_TAG=$(DOCKER_CONNECT_IMAGE_TAG) \ - CONNECT_VERSION=$* \ - DOCKER_PROJECT_IMAGE_TAG=$(DOCKER_PROJECT_IMAGE_TAG) \ - $(DOCKER_COMPOSE) -p $(PROJECT_NAME)-$(subst .,-,$(CONNECT_VERSION)) up -V --abort-on-container-exit --no-build + CONNECT_BOOTSTRAP_SECRETKEY=$(CONNECT_BOOTSTRAP_SECRETKEY) \ + DOCKER_CONNECT_IMAGE=$(DOCKER_CONNECT_IMAGE) \ + DOCKER_CONNECT_IMAGE_TAG=$(DOCKER_CONNECT_IMAGE_TAG) \ + CONNECT_VERSION=$* \ + DOCKER_PROJECT_IMAGE_TAG=$(DOCKER_PROJECT_IMAGE_TAG) \ + $(DOCKER_COMPOSE) -p $(PROJECT_NAME)-$(subst .,-,$(CONNECT_VERSION)) up -V --abort-on-container-exit --no-build # Show help message. help: - @echo "Makefile Targets:" - @echo " all (default) Run test suite for all Connect versions." - @echo " latest Run test suite for latest Connect version." - @echo " preview Run test suite for preview Connect version." - @echo " Run test suite for the specified Connect version. (e.g., make 2024.05.0)" - @echo " up Start Docker Compose for all Connect version." - @echo " down Tear down Docker resources for all Connect versions." - @echo " clean Clean up the project directory." - @echo " help Show this help message." - @echo - @echo "Common Usage:" - @echo " make -j 4 Run test suite in parallel for all Connect versions." - @echo " make latest Run test suite for latest Connect version." - @echo " make preview Run test suite for preview Connect version." - @echo " make 2024.05.0 Run test suite for specific Connect version." + @echo "Makefile Targets:" + @echo " all (default) Run test suite for all Connect versions." + @echo " latest Run test suite for latest Connect version." + @echo " preview Run test suite for preview Connect version." + @echo " Run test suite for the specified Connect version. (e.g., make 2024.05.0)" + @echo " up Start Docker Compose for all Connect version." + @echo " down Tear down Docker resources for all Connect versions." + @echo " clean Clean up the project directory." + @echo " help Show this help message." + @echo + @echo "Common Usage:" + @echo " make -j 4 Run test suite in parallel for all Connect versions." + @echo " make latest Run test suite for latest Connect version." + @echo " make preview Run test suite for preview Connect version." + @echo " make 2024.05.0 Run test suite for specific Connect version." # Run tests. # # Typically call from docker-compose.yaml. Assumes Connect server is running # on local network. test: - mkdir -p logs - set -o pipefail; \ - CONNECT_VERSION=${CONNECT_VERSION} \ - CONNECT_API_KEY="$(shell $(UV) run rsconnect bootstrap -i -s http://connect:3939 --raw)" \ - $(UV) run pytest -s --junit-xml=./reports/$(CONNECT_VERSION).xml | \ - tee ./logs/$(CONNECT_VERSION).log; + mkdir -p logs + set -o pipefail; \ + CONNECT_VERSION=${CONNECT_VERSION} \ + CONNECT_API_KEY="$(shell $(UV) run rsconnect bootstrap -i -s http://connect:3939 --raw)" \ + $(UV) run pytest -s --junit-xml=./reports/$(CONNECT_VERSION).xml | \ + tee ./logs/$(CONNECT_VERSION).log; From f91e3576ffa231c212be57166830cadd772fb4e5 Mon Sep 17 00:00:00 2001 From: tdstein Date: Thu, 7 Nov 2024 15:48:59 -0500 Subject: [PATCH 07/13] fix jobs test --- .github/workflows/ci.yaml | 1 + integration/tests/posit/connect/test_jobs.py | 4 ++-- integration/tests/posit/connect/test_packages.py | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 0b25bc7c..237858d8 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -41,6 +41,7 @@ jobs: matrix: CONNECT_VERSION: - preview + - 2024.09.0 - 2024.08.0 - 2024.06.0 - 2024.05.0 diff --git a/integration/tests/posit/connect/test_jobs.py b/integration/tests/posit/connect/test_jobs.py index 6b978943..496ccc66 100644 --- a/integration/tests/posit/connect/test_jobs.py +++ b/integration/tests/posit/connect/test_jobs.py @@ -48,5 +48,5 @@ def test_find_by(self): bundle = content.bundles.create(path) bundle.deploy() - jobs = content.jobs.find_by(status=1) - assert len(jobs) == 1 + jobs = content.jobs.find_by() + assert len(jobs) != 0 diff --git a/integration/tests/posit/connect/test_packages.py b/integration/tests/posit/connect/test_packages.py index 02ae5a96..f322ddf9 100644 --- a/integration/tests/posit/connect/test_packages.py +++ b/integration/tests/posit/connect/test_packages.py @@ -9,7 +9,7 @@ @pytest.mark.skipif( - CONNECT_VERSION <= version.parse("2024.09.0"), + CONNECT_VERSION < version.parse("2024.10.0-dev"), reason="Packages API unavailable", ) class TestPackages: From b892026c7460942c47f789c706b843f3fa8e49b1 Mon Sep 17 00:00:00 2001 From: tdstein Date: Thu, 7 Nov 2024 16:05:06 -0500 Subject: [PATCH 08/13] fix jobs test --- integration/tests/posit/connect/test_jobs.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/integration/tests/posit/connect/test_jobs.py b/integration/tests/posit/connect/test_jobs.py index 496ccc66..100b70c9 100644 --- a/integration/tests/posit/connect/test_jobs.py +++ b/integration/tests/posit/connect/test_jobs.py @@ -8,6 +8,10 @@ from . import CONNECT_VERSION +@pytest.mark.skipif( + CONNECT_VERSION <= version.parse("2023.01.1"), + reason="Quarto not available", +) class TestJobs: @classmethod def setup_class(cls): @@ -19,10 +23,6 @@ def teardown_class(cls): cls.content.delete() assert cls.client.content.count() == 0 - @pytest.mark.skipif( - CONNECT_VERSION <= version.parse("2023.01.1"), - reason="Quarto not available", - ) def test(self): content = self.content @@ -46,7 +46,8 @@ def test_find_by(self): path = str(path) bundle = content.bundles.create(path) - bundle.deploy() + task = bundle.deploy() + task.wait_for() - jobs = content.jobs.find_by() + jobs = content.jobs.reload() assert len(jobs) != 0 From 3895d723ed23dc6911f4efea6c0e194ba8f21695 Mon Sep 17 00:00:00 2001 From: tdstein Date: Fri, 8 Nov 2024 10:54:58 -0500 Subject: [PATCH 09/13] remove cache, fix packages implementation, enable packages api in integration tests --- integration/compose.yaml | 2 + integration/tests/posit/connect/__init__.py | 7 +- integration/tests/posit/connect/test_jobs.py | 2 +- .../tests/posit/connect/test_packages.py | 8 +- src/posit/connect/packages.py | 15 +++- src/posit/connect/resources.py | 86 ++++++++++--------- tests/posit/connect/test_jobs.py | 25 ------ 7 files changed, 67 insertions(+), 78 deletions(-) diff --git a/integration/compose.yaml b/integration/compose.yaml index 35bb6a81..79e6973b 100644 --- a/integration/compose.yaml +++ b/integration/compose.yaml @@ -21,9 +21,11 @@ services: - test connect: image: ${DOCKER_CONNECT_IMAGE}:${DOCKER_CONNECT_IMAGE_TAG} + pull_policy: always environment: - CONNECT_BOOTSTRAP_ENABLED=true - CONNECT_BOOTSTRAP_SECRETKEY=${CONNECT_BOOTSTRAP_SECRETKEY} + - CONNECT_APPLICATIONS_PACKAGEAUDITINGENABLED=true networks: - test privileged: true diff --git a/integration/tests/posit/connect/__init__.py b/integration/tests/posit/connect/__init__.py index 8fcd91f1..1328517d 100644 --- a/integration/tests/posit/connect/__init__.py +++ b/integration/tests/posit/connect/__init__.py @@ -1,7 +1,8 @@ -from packaging import version +from packaging.version import parse from posit import connect client = connect.Client() -CONNECT_VERSION = version.parse(client.version) -print(CONNECT_VERSION) +version = client.version +assert version +CONNECT_VERSION = parse(version) diff --git a/integration/tests/posit/connect/test_jobs.py b/integration/tests/posit/connect/test_jobs.py index 100b70c9..3cb32527 100644 --- a/integration/tests/posit/connect/test_jobs.py +++ b/integration/tests/posit/connect/test_jobs.py @@ -49,5 +49,5 @@ def test_find_by(self): task = bundle.deploy() task.wait_for() - jobs = content.jobs.reload() + jobs = content.jobs assert len(jobs) != 0 diff --git a/integration/tests/posit/connect/test_packages.py b/integration/tests/posit/connect/test_packages.py index f322ddf9..1d56c420 100644 --- a/integration/tests/posit/connect/test_packages.py +++ b/integration/tests/posit/connect/test_packages.py @@ -28,13 +28,13 @@ def teardown_class(cls): cls.content.delete() def test(self): - # assert self.client.packages + assert self.client.packages assert self.content.packages def test_find_by(self): - # package = self.client.packages.find_by(name="flask") - # assert package - # assert package["name"] == "flask" + package = self.client.packages.find_by(name="flask") + assert package + assert package["name"] == "flask" package = self.content.packages.find_by(name="flask") assert package diff --git a/src/posit/connect/packages.py b/src/posit/connect/packages.py index b770584f..178ca4c5 100644 --- a/src/posit/connect/packages.py +++ b/src/posit/connect/packages.py @@ -1,7 +1,7 @@ from __future__ import annotations import posixpath -from typing import List, Literal, Optional, TypedDict, overload +from typing import Generator, Literal, Optional, TypedDict, overload from typing_extensions import NotRequired, Required, Unpack @@ -109,6 +109,9 @@ class _Package(TypedDict): language: Required[Literal["python", "r"]] """Programming language ecosystem, options are 'python' and 'r'""" + language_version: Required[str] + """Programming language version""" + name: Required[str] """The package name""" @@ -139,12 +142,13 @@ def __init__(self, ctx, path): def _create_instance(self, path, /, **attributes): return Package(self._ctx, **attributes) - def fetch(self, **conditions) -> List["Package"]: + def fetch(self, **conditions) -> Generator["Package"]: # todo - add pagination support to ActiveSequence url = self._ctx.url + self._path paginator = Paginator(self._ctx.session, url, conditions) - results = paginator.fetch_results() - return [self._create_instance("", **result) for result in results] + for page in paginator.fetch_pages(): + results = page.results + yield from (self._create_instance("", **result) for result in results) def find(self, uid): raise NotImplementedError("The 'find' method is not support by the Packages API.") @@ -153,6 +157,9 @@ class _FindBy(TypedDict, total=False): language: NotRequired[Literal["python", "r"]] """Programming language ecosystem, options are 'python' and 'r'""" + language_version: NotRequired[str] + """Programming language version""" + name: NotRequired[str] """The package name""" diff --git a/src/posit/connect/resources.py b/src/posit/connect/resources.py index bb05b9dc..9bbe5064 100644 --- a/src/posit/connect/resources.py +++ b/src/posit/connect/resources.py @@ -4,9 +4,18 @@ import warnings from abc import ABC, abstractmethod from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, Generic, List, Optional, Sequence, TypeVar, overload - -from typing_extensions import Self +from itertools import islice +from typing import ( + TYPE_CHECKING, + Any, + Generic, + Iterable, + List, + Optional, + Sequence, + TypeVar, + overload, +) if TYPE_CHECKING: import requests @@ -101,14 +110,13 @@ def __init__(self, ctx: Context, path: str, uid: str = "guid"): self._ctx = ctx self._path = path self._uid = uid - self._cache = None @abstractmethod def _create_instance(self, path: str, /, **kwargs: Any) -> T: """Create an instance of 'T'.""" raise NotImplementedError() - def fetch(self, **conditions) -> List[T]: + def fetch(self, **conditions) -> Iterable[T]: """Fetch the collection. Fetches the collection directly from Connect. This operation does not effect the cache state. @@ -122,61 +130,57 @@ def fetch(self, **conditions) -> List[T]: results = response.json() return [self._to_instance(result) for result in results] - def reload(self) -> Self: - """Reloads the collection from Connect. - - Returns - ------- - Self - """ - self._cache = None - return self - def _to_instance(self, result: dict) -> T: """Converts a result into an instance of T.""" uid = result[self._uid] path = posixpath.join(self._path, uid) return self._create_instance(path, **result) - @property - def _data(self) -> List[T]: - """Get the collection. - - Fetches the collection from Connect and caches the result. Subsequent invocations return the cached results unless the cache is explicitly reset. - - Returns - ------- - List[T] - - See Also - -------- - cached - reload - """ - if self._cache is None: - self._cache = self.fetch() - return self._cache - @overload def __getitem__(self, index: int) -> T: ... @overload def __getitem__(self, index: slice) -> Sequence[T]: ... - def __getitem__(self, index): - return self._data[index] + def __getitem__(self, index) -> Sequence[T] | T: + data = self.fetch() + + if isinstance(index, int): + if index < 0: + # Handle negative indexing + data = list(data) + return data[index] + for i, value in enumerate(data): + if i == index: + return value + raise KeyError(f"Index {index} is out of range.") + + if isinstance(index, slice): + # Handle slicing with islice + start = index.start or 0 + stop = index.stop + step = index.step or 1 + if step == 0: + raise ValueError("slice step cannot be zero") + return [ + value + for i, value in enumerate(islice(data, start, stop)) + if (i + start) % step == 0 + ] + + raise TypeError(f"Index must be int or slice, not {type(index).__name__}.") def __iter__(self): - return iter(self._data) + return iter(self.fetch()) def __len__(self) -> int: - return len(self._data) + return len(list(self.fetch())) def __str__(self) -> str: - return str(self._data) + return str(list(self.fetch())) def __repr__(self) -> str: - return repr(self._data) + return repr(list(self.fetch())) class ActiveFinderMethods(ActiveSequence[T], ABC): @@ -220,5 +224,5 @@ def find_by(self, **conditions) -> Optional[T]: Optional[T] The first record matching the conditions, or `None` if no match is found. """ - collection = self.fetch() + collection = self.fetch(**conditions) return next((v for v in collection if v.items() >= conditions.items()), None) diff --git a/tests/posit/connect/test_jobs.py b/tests/posit/connect/test_jobs.py index 252bd2e8..6e6bc0c5 100644 --- a/tests/posit/connect/test_jobs.py +++ b/tests/posit/connect/test_jobs.py @@ -87,31 +87,6 @@ def test(self): assert job["key"] == "tHawGvHZTosJA2Dx" -class TestJobsReload: - @responses.activate - def test(self): - responses.get( - "https://connect.example/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066", - json=load_mock("v1/content/f2f37341-e21d-3d80-c698-a935ad614066.json"), - ) - - mock_get = responses.get( - "https://connect.example/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/jobs", - json=load_mock("v1/content/f2f37341-e21d-3d80-c698-a935ad614066/jobs.json"), - ) - - c = Client("https://connect.example", "12345") - content = c.content.get("f2f37341-e21d-3d80-c698-a935ad614066") - - assert len(content.jobs) == 1 - assert mock_get.call_count == 1 - - content.jobs.reload() - - assert len(content.jobs) == 1 - assert mock_get.call_count == 2 - - class TestJobDestory: @responses.activate def test(self): From 2af9e39c0cdf2e193aaf3640a6c8a3c12997794a Mon Sep 17 00:00:00 2001 From: tdstein Date: Tue, 12 Nov 2024 10:47:25 -0500 Subject: [PATCH 10/13] add overload definitions for fetch --- src/posit/connect/packages.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/posit/connect/packages.py b/src/posit/connect/packages.py index 178ca4c5..3cdf92dc 100644 --- a/src/posit/connect/packages.py +++ b/src/posit/connect/packages.py @@ -142,6 +142,22 @@ def __init__(self, ctx, path): def _create_instance(self, path, /, **attributes): return Package(self._ctx, **attributes) + class _Fetch(TypedDict, total=False): + language: Required[Literal["python", "r"]] + """Programming language ecosystem, options are 'python' and 'r'""" + + name: Required[str] + """The package name""" + + version: Required[str] + """The package version""" + + @overload + def fetch(self, **conditions: Unpack[_Fetch]): ... + + @overload + def fetch(self, **conditions): ... + def fetch(self, **conditions) -> Generator["Package"]: # todo - add pagination support to ActiveSequence url = self._ctx.url + self._path From 4b0bed2e446a7b22ce0ddd5662a523388493b1da Mon Sep 17 00:00:00 2001 From: tdstein Date: Tue, 12 Nov 2024 12:27:16 -0500 Subject: [PATCH 11/13] fix linting errors --- src/posit/connect/packages.py | 4 ++-- src/posit/connect/resources.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/posit/connect/packages.py b/src/posit/connect/packages.py index 3cdf92dc..863b866e 100644 --- a/src/posit/connect/packages.py +++ b/src/posit/connect/packages.py @@ -30,7 +30,7 @@ class ContentPackages(ActiveFinderMethods["ContentPackage"], ActiveSequence["Con def __init__(self, ctx, path): super().__init__(ctx, path, "name") - def _create_instance(self, path, /, **attributes): + def _create_instance(self, path, /, **attributes): # noqa: ARG002 return ContentPackage(self._ctx, **attributes) def fetch(self, **conditions): @@ -139,7 +139,7 @@ class Packages(ActiveFinderMethods["Package"], ActiveSequence["Package"]): def __init__(self, ctx, path): super().__init__(ctx, path, "name") - def _create_instance(self, path, /, **attributes): + def _create_instance(self, path, /, **attributes): # noqa: ARG002 return Package(self._ctx, **attributes) class _Fetch(TypedDict, total=False): diff --git a/src/posit/connect/resources.py b/src/posit/connect/resources.py index ab5701a5..cf774a19 100644 --- a/src/posit/connect/resources.py +++ b/src/posit/connect/resources.py @@ -176,7 +176,7 @@ def __len__(self) -> int: return len(list(self.fetch())) def __iter__(self): - return iter(self._data) + return iter(self.fetch()) def __str__(self) -> str: return str(list(self.fetch())) From 0c9f37262acfe7b58778b337764e7dabdd165ebf Mon Sep 17 00:00:00 2001 From: tdstein Date: Tue, 12 Nov 2024 12:59:35 -0500 Subject: [PATCH 12/13] apply pr feedback --- src/posit/connect/packages.py | 4 +- src/posit/connect/resources.py | 70 ++++++++++++++++++---------------- 2 files changed, 40 insertions(+), 34 deletions(-) diff --git a/src/posit/connect/packages.py b/src/posit/connect/packages.py index 863b866e..7e0d9279 100644 --- a/src/posit/connect/packages.py +++ b/src/posit/connect/packages.py @@ -128,7 +128,7 @@ class _Package(TypedDict): """The numerical identifier of the application this package is associated with""" app_guid: Required[str] - """The numerical identifier of the application this package is associated with""" + """The unique identifier of the application this package is associated with""" def __init__(self, ctx, /, **attributes: Unpack[_Package]): # todo - passing "" is a hack since path isn't needed. Instead, this class should inherit from Resource, but ActiveSequence is designed to operate on Active. That should change. @@ -192,7 +192,7 @@ class _FindBy(TypedDict, total=False): """The numerical identifier of the application this package is associated with""" app_guid: NotRequired[str] - """The numerical identifier of the application this package is associated with""" + """The unique identifier of the application this package is associated with""" @overload def find_by(self, **conditions: Unpack[_FindBy]) -> "Package | None": diff --git a/src/posit/connect/resources.py b/src/posit/connect/resources.py index cf774a19..17c9894c 100644 --- a/src/posit/connect/resources.py +++ b/src/posit/connect/resources.py @@ -4,7 +4,6 @@ import warnings from abc import ABC, abstractmethod from dataclasses import dataclass -from itertools import islice from typing import ( TYPE_CHECKING, Any, @@ -17,6 +16,8 @@ overload, ) +from typing_extensions import Self + if TYPE_CHECKING: import requests @@ -112,6 +113,7 @@ def __init__(self, ctx: Context, path: str, uid: str = "guid"): self._ctx = ctx self._path = path self._uid = uid + self._cache: Optional[List[T]] = None @abstractmethod def _create_instance(self, path: str, /, **kwargs: Any) -> T: @@ -132,57 +134,61 @@ def fetch(self, **conditions) -> Iterable[T]: results = response.json() return [self._to_instance(result) for result in results] + def reload(self) -> Self: + """Reloads the collection from Connect. + + Returns + ------- + Self + """ + self._cache = None + return self + def _to_instance(self, result: dict) -> T: """Converts a result into an instance of T.""" uid = result[self._uid] path = posixpath.join(self._path, uid) return self._create_instance(path, **result) + @property + def _data(self) -> List[T]: + """Get the collection. + + Fetches the collection from Connect and caches the result. Subsequent invocations return the cached results unless the cache is explicitly reset. + + Returns + ------- + List[T] + + See Also + -------- + cached + reload + """ + if self._cache is None: + self._cache = list(self.fetch()) + return self._cache + @overload def __getitem__(self, index: int) -> T: ... @overload def __getitem__(self, index: slice) -> Sequence[T]: ... - def __getitem__(self, index) -> Sequence[T] | T: - data = self.fetch() - - if isinstance(index, int): - if index < 0: - # Handle negative indexing - data = list(data) - return data[index] - for i, value in enumerate(data): - if i == index: - return value - raise KeyError(f"Index {index} is out of range.") - - if isinstance(index, slice): - # Handle slicing with islice - start = index.start or 0 - stop = index.stop - step = index.step or 1 - if step == 0: - raise ValueError("slice step cannot be zero") - return [ - value - for i, value in enumerate(islice(data, start, stop)) - if (i + start) % step == 0 - ] - - raise TypeError(f"Index must be int or slice, not {type(index).__name__}.") + def __getitem__(self, index): + return self._data[index] def __len__(self) -> int: - return len(list(self.fetch())) + return len(self._data) def __iter__(self): - return iter(self.fetch()) + return iter(self._data) def __str__(self) -> str: - return str(list(self.fetch())) + return str(self._data) def __repr__(self) -> str: - return repr(list(self.fetch())) + return repr(self._data) class ActiveFinderMethods(ActiveSequence[T]): From 9d9d94824fe46e632138dce0e84b115a5a39ed98 Mon Sep 17 00:00:00 2001 From: tdstein Date: Tue, 12 Nov 2024 14:05:58 -0500 Subject: [PATCH 13/13] adds type: ignore for now --- src/posit/connect/packages.py | 28 +++++----------------------- src/posit/connect/paginator.py | 4 ++-- src/posit/connect/resources.py | 2 +- 3 files changed, 8 insertions(+), 26 deletions(-) diff --git a/src/posit/connect/packages.py b/src/posit/connect/packages.py index 7e0d9279..27e24475 100644 --- a/src/posit/connect/packages.py +++ b/src/posit/connect/packages.py @@ -1,7 +1,7 @@ from __future__ import annotations import posixpath -from typing import Generator, Literal, Optional, TypedDict, overload +from typing import Generator, Literal, Optional, TypedDict from typing_extensions import NotRequired, Required, Unpack @@ -57,8 +57,7 @@ class _FindBy(TypedDict, total=False): hash: NotRequired[Optional[str]] """Package description hash for R packages.""" - @overload - def find_by(self, **conditions: Unpack[_FindBy]): + def find_by(self, **conditions: Unpack[_FindBy]): # type: ignore """ Find the first record matching the specified conditions. @@ -86,11 +85,6 @@ def find_by(self, **conditions: Unpack[_FindBy]): Optional[T] The first record matching the specified conditions, or `None` if no such record exists. """ - - @overload - def find_by(self, **conditions): ... - - def find_by(self, **conditions): return super().find_by(**conditions) @@ -152,16 +146,10 @@ class _Fetch(TypedDict, total=False): version: Required[str] """The package version""" - @overload - def fetch(self, **conditions: Unpack[_Fetch]): ... - - @overload - def fetch(self, **conditions): ... - - def fetch(self, **conditions) -> Generator["Package"]: + def fetch(self, **conditions: Unpack[_Fetch]) -> Generator["Package"]: # type: ignore # todo - add pagination support to ActiveSequence url = self._ctx.url + self._path - paginator = Paginator(self._ctx.session, url, conditions) + paginator = Paginator(self._ctx.session, url, dict(**conditions)) for page in paginator.fetch_pages(): results = page.results yield from (self._create_instance("", **result) for result in results) @@ -194,8 +182,7 @@ class _FindBy(TypedDict, total=False): app_guid: NotRequired[str] """The unique identifier of the application this package is associated with""" - @overload - def find_by(self, **conditions: Unpack[_FindBy]) -> "Package | None": + def find_by(self, **conditions: Unpack[_FindBy]) -> "Package | None": # type: ignore """ Find the first record matching the specified conditions. @@ -223,9 +210,4 @@ def find_by(self, **conditions: Unpack[_FindBy]) -> "Package | None": Optional[Package] The first record matching the specified conditions, or `None` if no such record exists. """ - - @overload - def find_by(self, **conditions) -> "Package | None": ... - - def find_by(self, **conditions) -> "Package | None": return super().find_by(**conditions) diff --git a/src/posit/connect/paginator.py b/src/posit/connect/paginator.py index 2889801c..5086057b 100644 --- a/src/posit/connect/paginator.py +++ b/src/posit/connect/paginator.py @@ -1,7 +1,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, Generator, List +from typing import TYPE_CHECKING, Generator, List if TYPE_CHECKING: import requests @@ -45,7 +45,7 @@ def __init__( self, session: requests.Session, url: str, - params: dict[str, Any] | None = None, + params: dict | None = None, ) -> None: if params is None: params = {} diff --git a/src/posit/connect/resources.py b/src/posit/connect/resources.py index 17c9894c..90598e66 100644 --- a/src/posit/connect/resources.py +++ b/src/posit/connect/resources.py @@ -120,7 +120,7 @@ def _create_instance(self, path: str, /, **kwargs: Any) -> T: """Create an instance of 'T'.""" raise NotImplementedError() - def fetch(self, **conditions) -> Iterable[T]: + def fetch(self, **conditions: Any) -> Iterable[T]: """Fetch the collection. Fetches the collection directly from Connect. This operation does not effect the cache state.