From 4edb9f1f92c400c4957d307d5625f0457ef97fa4 Mon Sep 17 00:00:00 2001 From: Taylor Steinberg Date: Tue, 12 Nov 2024 14:10:41 -0500 Subject: [PATCH 1/2] feat: add packages and content packages (#313) Adds packages support. --- .github/workflows/ci.yaml | 1 + integration/Makefile | 49 ++-- integration/compose.yaml | 2 + integration/tests/posit/connect/__init__.py | 8 +- integration/tests/posit/connect/test_jobs.py | 23 +- .../tests/posit/connect/test_packages.py | 41 ++++ src/posit/connect/client.py | 10 +- src/posit/connect/content.py | 3 +- src/posit/connect/context.py | 4 +- src/posit/connect/packages.py | 213 ++++++++++++++++++ src/posit/connect/paginator.py | 4 +- src/posit/connect/resources.py | 25 +- .../packages.json | 7 + tests/posit/connect/__api__/v1/packages.json | 11 + .../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_jobs.py | 25 -- tests/posit/connect/test_packages.py | 53 +++++ 24 files changed, 430 insertions(+), 93 deletions(-) create mode 100644 integration/tests/posit/connect/test_packages.py create mode 100644 src/posit/connect/packages.py 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 create mode 100644 tests/posit/connect/test_packages.py 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/Makefile b/integration/Makefile index edc0ac50..8d54da48 100644 --- a/integration/Makefile +++ b/integration/Makefile @@ -13,33 +13,34 @@ CONNECT_BOOTSTRAP_SECRETKEY ?= $(shell head -c 32 /dev/random | base64) .PHONY: $(CONNECT_VERSIONS) \ all \ build \ - down \ - down-% \ - latest \ - test \ - up \ - up-% \ - help + down \ + down-% \ + latest \ + test \ + up \ + up-% \ + help # Versions -CONNECT_VERSIONS := 2024.08.0 \ +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.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 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 cb5b6e72..1328517d 100644 --- a/integration/tests/posit/connect/__init__.py +++ b/integration/tests/posit/connect/__init__.py @@ -1,8 +1,8 @@ -from packaging import version +from packaging.version import parse from posit import connect client = connect.Client() -client_version = client.version -assert client_version is not None -CONNECT_VERSION = version.parse(client_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 9617cf7e..3cb32527 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 @@ -36,3 +36,18 @@ 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) + task = bundle.deploy() + task.wait_for() + + 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 new file mode 100644 index 00000000..1d56c420 --- /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.10.0-dev"), + 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 5ef02c4d..e1ba808c 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 @@ -155,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) -> str | None: @@ -167,7 +168,7 @@ def version(self) -> str | None: str The version of the Posit Connect server. """ - return self.ctx.version + return self._ctx.version @property def me(self) -> User: @@ -269,6 +270,11 @@ def oauth(self) -> OAuth: """ return OAuth(self.resource_params, self.cfg.api_key) + @property + @requires(version="2024.10.0-dev") + 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 0ff2db75..a5498757 100644 --- a/src/posit/connect/content.py +++ b/src/posit/connect/content.py @@ -24,6 +24,7 @@ from .errors import ClientError from .jobs import JobsMixin from .oauth.associations import ContentItemAssociations +from .packages import ContentPackagesMixin as PackagesMixin from .permissions import Permissions from .resources import Resource, ResourceParameters, Resources from .vanities import VanityMixin @@ -171,7 +172,7 @@ class ContentItemOwner(Resource): pass -class ContentItem(JobsMixin, VanityMixin, Resource): +class ContentItem(JobsMixin, PackagesMixin, VanityMixin, Resource): class _AttrsBase(TypedDict, total=False): # # `name` will be set by other _Attrs classes # name: str 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 new file mode 100644 index 00000000..27e24475 --- /dev/null +++ b/src/posit/connect/packages.py @@ -0,0 +1,213 @@ +from __future__ import annotations + +import posixpath +from typing import Generator, Literal, Optional, TypedDict + +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 + + +class ContentPackage(Active): + class _Package(TypedDict): + language: Required[str] + name: Required[str] + version: Required[str] + hash: Required[Optional[str]] + + 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 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): # noqa: ARG002 + 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.") + + 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 find_by(self, **conditions: Unpack[_FindBy]): # type: ignore + """ + 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. + """ + return super().find_by(**conditions) + + +class ContentPackagesMixin(Active): + """Mixin class to add a packages attribute.""" + + @property + @requires(version="2024.10.0-dev") + def packages(self): + path = posixpath.join(self._path, "packages") + return ContentPackages(self._ctx, path) + + +class Package(Active): + 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""" + + 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""" + + app_id: Required[str] + """The numerical identifier of the application this package is associated with""" + + app_guid: Required[str] + """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. + super().__init__(ctx, "", **attributes) + + +class Packages(ActiveFinderMethods["Package"], ActiveSequence["Package"]): + def __init__(self, ctx, path): + super().__init__(ctx, path, "name") + + def _create_instance(self, path, /, **attributes): # noqa: ARG002 + 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""" + + 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, dict(**conditions)) + 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.") + + 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""" + + version: NotRequired[str] + """The package version""" + + hash: NotRequired[Optional[str]] + """Package description hash for R packages.""" + + 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 unique identifier of the application this package is associated with""" + + def find_by(self, **conditions: Unpack[_FindBy]) -> "Package | None": # type: ignore + """ + 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[Package] + The first record matching the specified conditions, or `None` if no such record exists. + """ + 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 bfef8365..90598e66 100644 --- a/src/posit/connect/resources.py +++ b/src/posit/connect/resources.py @@ -4,12 +4,23 @@ 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 import ( + TYPE_CHECKING, + Any, + Generic, + Iterable, + List, + Optional, + Sequence, + TypeVar, + overload, +) + +from typing_extensions import Self if TYPE_CHECKING: import requests - from ._typing_extensions import Self from .context import Context from .urls import Url @@ -102,14 +113,14 @@ def __init__(self, ctx: Context, path: str, uid: str = "guid"): self._ctx = ctx self._path = path self._uid = uid - self._cache = None + self._cache: Optional[List[T]] = None @abstractmethod 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: Any) -> Iterable[T]: """Fetch the collection. Fetches the collection directly from Connect. This operation does not effect the cache state. @@ -119,7 +130,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=conditions) results = response.json() return [self._to_instance(result) for result in results] @@ -155,7 +166,7 @@ def _data(self) -> List[T]: reload """ if self._cache is None: - self._cache = self.fetch() + self._cache = list(self.fetch()) return self._cache @overload @@ -221,5 +232,5 @@ def find_by(self, **conditions: Any) -> T | None: 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/__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/external/test_databricks.py b/tests/posit/connect/external/test_databricks.py index 05c9c670..4cb83fd1 100644 --- a/tests/posit/connect/external/test_databricks.py +++ b/tests/posit/connect/external/test_databricks.py @@ -49,7 +49,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"} @@ -59,7 +59,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 5f26f9cb..d0e28658 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 3f136f4e..27405fd7 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 f233e0bb..702f6716 100644 --- a/tests/posit/connect/oauth/test_oauth.py +++ b/tests/posit/connect/oauth/test_oauth.py @@ -24,7 +24,7 @@ def test_get_credentials(self): }, ) c = Client(api_key="12345", url="https://connect.example/") - c.ctx.version = None + c._ctx.version = None creds = c.oauth.get_credentials("cit") assert "access_token" in creds assert creds["access_token"] == "viewer-token" diff --git a/tests/posit/connect/oauth/test_sessions.py b/tests/posit/connect/oauth/test_sessions.py index 053c3c52..30d171a8 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 e6a07d3c..5a8d1b42 100644 --- a/tests/posit/connect/test_client.py +++ b/tests/posit/connect/test_client.py @@ -181,7 +181,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_jobs.py b/tests/posit/connect/test_jobs.py index 4b1102ae..d97df373 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): diff --git a/tests/posit/connect/test_packages.py b/tests/posit/connect/test_packages.py new file mode 100644 index 00000000..4d42f535 --- /dev/null +++ b/tests/posit/connect/test_packages.py @@ -0,0 +1,53 @@ +import pytest +import responses + +from posit.connect.client import Client + +from .api import load_mock # type: ignore + + +class TestPackagesMixin: + @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"), + ) + + 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"), + ) + + 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 + def test(self): + c = Client("https://connect.example", "12345") + c._ctx.version = None + + with pytest.raises(NotImplementedError): + c.packages.find("posit") + + +class TestPackagesFindBy: + @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 + + package = c.packages.find_by(name="posit") + assert package + assert package["name"] == "posit" + assert mock_get.call_count == 1 From 540c693079db9c16b7813ab507c544456eea3006 Mon Sep 17 00:00:00 2001 From: Taylor Steinberg Date: Tue, 12 Nov 2024 16:56:51 -0500 Subject: [PATCH 2/2] refactor: revert to typing-extensions library (#333) Revert to using the typing-extensions package since it performs the same logic introduced in the `_typing_extensions.py` module copied from Shiny. For example, here is the `Self` implementation in typing-extensions, which keys off of the python version to determine the correct import. https://github.com/python/typing_extensions/blob/main/src/typing_extensions.py#L2229-L2247 --- src/posit/connect/_typing_extensions.py | 42 ------------------------- src/posit/connect/content.py | 3 +- src/posit/connect/jobs.py | 3 +- src/posit/connect/oauth/oauth.py | 3 +- src/posit/connect/users.py | 3 +- src/posit/connect/vanities.py | 3 +- 6 files changed, 10 insertions(+), 47 deletions(-) delete mode 100644 src/posit/connect/_typing_extensions.py diff --git a/src/posit/connect/_typing_extensions.py b/src/posit/connect/_typing_extensions.py deleted file mode 100644 index 951bb118..00000000 --- a/src/posit/connect/_typing_extensions.py +++ /dev/null @@ -1,42 +0,0 @@ -# # Within file flags to ignore unused imports -# flake8: noqa: F401 -# pyright: reportUnusedImport=false - -# Extended from https://github.com/posit-dev/py-shiny/blob/main/shiny/_typing_extensions.py - -__all__ = ( - "Required", - "NotRequired", - "Self", - "TypedDict", - "Unpack", -) - - -import sys - -if sys.version_info >= (3, 10): - from typing import TypeGuard -else: - from typing_extensions import TypeGuard - -# Even though TypedDict is available in Python 3.8, because it's used with NotRequired, -# they should both come from the same typing module. -# https://peps.python.org/pep-0655/#usage-in-python-3-11 -if sys.version_info >= (3, 11): - from typing import NotRequired, Required, Self, TypedDict, Unpack -else: - from typing_extensions import ( - NotRequired, - Required, - Self, - TypedDict, - Unpack, - ) - - -# The only purpose of the following line is so that pyright will put all of the -# conditional imports into the .pyi file when generating type stubs. Without this line, -# pyright will not include the above imports in the generated .pyi file, and it will -# result in a lot of red squiggles in user code. -_: "TypeGuard | NotRequired | Required | TypedDict | Self | Unpack" # type:ignore diff --git a/src/posit/connect/content.py b/src/posit/connect/content.py index a5498757..404867b7 100644 --- a/src/posit/connect/content.py +++ b/src/posit/connect/content.py @@ -15,9 +15,10 @@ overload, ) +from typing_extensions import NotRequired, Required, TypedDict, Unpack + from . import tasks from ._api import ApiDictEndpoint, JsonifiableDict -from ._typing_extensions import NotRequired, Required, TypedDict, Unpack from .bundles import Bundles from .context import Context from .env import EnvVars diff --git a/src/posit/connect/jobs.py b/src/posit/connect/jobs.py index bcf97092..96fff315 100644 --- a/src/posit/connect/jobs.py +++ b/src/posit/connect/jobs.py @@ -1,7 +1,8 @@ import posixpath from typing import Any, Literal, Optional, overload -from ._typing_extensions import NotRequired, Required, TypedDict, Unpack +from typing_extensions import NotRequired, Required, TypedDict, Unpack + from .context import Context from .resources import Active, ActiveFinderMethods, ActiveSequence, Resource diff --git a/src/posit/connect/oauth/oauth.py b/src/posit/connect/oauth/oauth.py index 18cd7f5c..306170b8 100644 --- a/src/posit/connect/oauth/oauth.py +++ b/src/posit/connect/oauth/oauth.py @@ -2,7 +2,8 @@ from typing import Optional -from .._typing_extensions import TypedDict +from typing_extensions import TypedDict + from ..resources import ResourceParameters, Resources from .integrations import Integrations from .sessions import Sessions diff --git a/src/posit/connect/users.py b/src/posit/connect/users.py index cc206da8..dd9c6833 100644 --- a/src/posit/connect/users.py +++ b/src/posit/connect/users.py @@ -4,8 +4,9 @@ from typing import List, Literal +from typing_extensions import NotRequired, Required, TypedDict, Unpack + from . import me -from ._typing_extensions import NotRequired, Required, TypedDict, Unpack from .content import Content from .paginator import Paginator from .resources import Resource, ResourceParameters, Resources diff --git a/src/posit/connect/vanities.py b/src/posit/connect/vanities.py index c0345eed..5f9a1679 100644 --- a/src/posit/connect/vanities.py +++ b/src/posit/connect/vanities.py @@ -1,6 +1,7 @@ from typing import Callable, List, Optional -from ._typing_extensions import NotRequired, Required, TypedDict, Unpack +from typing_extensions import NotRequired, Required, TypedDict, Unpack + from .errors import ClientError from .resources import Resource, ResourceParameters, Resources