From f709cc1d8402dd76f585f5562e30ec7733eafa51 Mon Sep 17 00:00:00 2001 From: Taylor Steinberg Date: Fri, 13 Dec 2024 11:37:24 -0500 Subject: [PATCH] refactor: use static duck typing for jobs and packages (#357) Co-authored-by: Barret Schloerke --- .../tests/posit/connect/test_environments.py | 4 +- integration/tests/posit/connect/test_jobs.py | 4 + src/posit/connect/client.py | 10 +- src/posit/connect/content.py | 20 +- src/posit/connect/environments.py | 11 +- src/posit/connect/jobs.py | 253 +++++------------- src/posit/connect/packages.py | 204 ++++---------- src/posit/connect/resources.py | 180 ++----------- tests/posit/connect/test_jobs.py | 11 +- tests/posit/connect/test_packages.py | 31 --- 10 files changed, 174 insertions(+), 554 deletions(-) diff --git a/integration/tests/posit/connect/test_environments.py b/integration/tests/posit/connect/test_environments.py index 0d82f5b9..5d4110f4 100644 --- a/integration/tests/posit/connect/test_environments.py +++ b/integration/tests/posit/connect/test_environments.py @@ -15,7 +15,9 @@ class TestEnvironments: def setup_class(cls): cls.client = connect.Client() cls.environment = cls.client.environments.create( - title="title", name="name", cluster_name="Kubernetes" + title="title", + name="name", + cluster_name="Kubernetes", ) @classmethod diff --git a/integration/tests/posit/connect/test_jobs.py b/integration/tests/posit/connect/test_jobs.py index 3cb32527..75c90530 100644 --- a/integration/tests/posit/connect/test_jobs.py +++ b/integration/tests/posit/connect/test_jobs.py @@ -51,3 +51,7 @@ def test_find_by(self): jobs = content.jobs assert len(jobs) != 0 + + job = jobs[0] + key = job["key"] + assert content.jobs.find_by(key=key) == job diff --git a/src/posit/connect/client.py b/src/posit/connect/client.py index 2f549b2d..faaf7ba6 100644 --- a/src/posit/connect/client.py +++ b/src/posit/connect/client.py @@ -14,8 +14,7 @@ from .groups import Groups from .metrics import Metrics from .oauth import OAuth -from .packages import Packages -from .resources import ResourceParameters, _ResourceSequence +from .resources import ResourceParameters, _PaginatedResourceSequence, _ResourceSequence from .tags import Tags from .tasks import Tasks from .users import User, Users @@ -23,6 +22,7 @@ if TYPE_CHECKING: from .environments import Environments + from .packages import _Packages class Client(ContextManager): @@ -297,9 +297,9 @@ 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") + @requires(version="2024.11.0") + def packages(self) -> _Packages: + return _PaginatedResourceSequence(self._ctx, "v1/packages", uid="name") @property def vanities(self) -> Vanities: diff --git a/src/posit/connect/content.py b/src/posit/connect/content.py index 0ed22a02..77a31320 100644 --- a/src/posit/connect/content.py +++ b/src/posit/connect/content.py @@ -20,19 +20,20 @@ from . import tasks from ._api import ApiDictEndpoint, JsonifiableDict from .bundles import Bundles +from .context import requires from .env import EnvVars 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 .resources import Active, Resource, ResourceParameters, Resources, _ResourceSequence from .tags import ContentItemTags from .vanities import VanityMixin from .variants import Variants if TYPE_CHECKING: from .context import Context + from .jobs import Jobs + from .packages import _ContentPackages from .tasks import Task @@ -174,7 +175,7 @@ class ContentItemOwner(Resource): pass -class ContentItem(JobsMixin, PackagesMixin, VanityMixin, Resource): +class ContentItem(Active, VanityMixin, Resource): class _AttrsBase(TypedDict, total=False): # # `name` will be set by other _Attrs classes # name: str @@ -511,6 +512,17 @@ def tags(self) -> ContentItemTags: content_guid=self["guid"], ) + @property + def jobs(self) -> Jobs: + path = posixpath.join(self._path, "jobs") + return _ResourceSequence(self._ctx, path, uid="key") + + @property + @requires(version="2024.11.0") + def packages(self) -> _ContentPackages: + path = posixpath.join(self._path, "packages") + return _ResourceSequence(self._ctx, path, uid="name") + class Content(Resources): """Content resource. diff --git a/src/posit/connect/environments.py b/src/posit/connect/environments.py index d6c77cc9..4b229fa9 100644 --- a/src/posit/connect/environments.py +++ b/src/posit/connect/environments.py @@ -2,12 +2,15 @@ from abc import abstractmethod from collections.abc import Mapping, Sized -from typing import ( + +from typing_extensions import ( Any, List, Literal, Protocol, + SupportsIndex, TypedDict, + overload, runtime_checkable, ) @@ -93,6 +96,12 @@ def update( @runtime_checkable class Environments(Sized, Protocol): + @overload + def __getitem__(self, index: SupportsIndex) -> Environment: ... + + @overload + def __getitem__(self, index: slice) -> List[Environment]: ... + def create( self, *, diff --git a/src/posit/connect/jobs.py b/src/posit/connect/jobs.py index 96fff315..977547eb 100644 --- a/src/posit/connect/jobs.py +++ b/src/posit/connect/jobs.py @@ -1,10 +1,16 @@ -import posixpath -from typing import Any, Literal, Optional, overload - -from typing_extensions import NotRequired, Required, TypedDict, Unpack - -from .context import Context -from .resources import Active, ActiveFinderMethods, ActiveSequence, Resource +from __future__ import annotations + +from abc import abstractmethod +from collections.abc import Mapping, Sized +from typing import ( + Any, + List, + Literal, + Protocol, + SupportsIndex, + overload, + runtime_checkable, +) JobTag = Literal[ "unknown", @@ -33,82 +39,14 @@ ] -class Job(Active): - class _Job(TypedDict): - # Identifiers - id: Required[str] - """A unique identifier for the job.""" - - ppid: Required[Optional[str]] - """Identifier of the parent process.""" - - pid: Required[str] - """Identifier of the process running the job.""" - - key: Required[str] - """A unique key to identify this job.""" - - remote_id: Required[Optional[str]] - """Identifier for off-host execution configurations.""" - - app_id: Required[str] - """Identifier of the parent content associated with the job.""" - - variant_id: Required[str] - """Identifier of the variant responsible for the job.""" - - bundle_id: Required[str] - """Identifier of the content bundle linked to the job.""" - - # Timestamps - start_time: Required[str] - """RFC3339 timestamp indicating when the job started.""" - - end_time: Required[Optional[str]] - """RFC3339 timestamp indicating when the job finished.""" - - last_heartbeat_time: Required[str] - """RFC3339 timestamp of the last recorded activity for the job.""" - - queued_time: Required[Optional[str]] - """RFC3339 timestamp when the job was added to the queue.""" - - # Status and Exit Information - status: Required[Literal[0, 1, 2]] - """Current status. Options are 0 (Active), 1 (Finished), and 2 (Finalized)""" - - exit_code: Required[Optional[int]] - """The job's exit code, available after completion.""" - - # Environment Information - hostname: Required[str] - """Name of the node processing the job.""" - - cluster: Required[Optional[str]] - """Location where the job runs, either 'Local' or the cluster name.""" - - image: Required[Optional[str]] - """Location of the content in clustered environments.""" +StatusCode = Literal[0, 1, 2] - run_as: Required[str] - """UNIX user responsible for executing the job.""" - - # Queue and Scheduling Information - queue_name: Required[Optional[str]] - """Name of the queue processing the job, relevant for scheduled reports.""" - - # Job Metadata - tag: Required[JobTag] - """A tag categorizing the job type. Options are build_jupyter, build_report, build_site, configure_report, git, packrat_restore, python_restore, render_shiny, run_api, run_app, run_bokeh_app, run_dash_app, run_fastapi_app, run_pyshiny_app, run_python_api, run_streamlit, run_tensorflow, run_voila_app, testing, unknown, val_py_ext_pkg, val_r_ext_pkg, and val_r_install.""" - - def __init__(self, ctx: Context, path: str, /, **attributes: Unpack[_Job]): - super().__init__(ctx, path, **attributes) +class Job(Mapping[str, Any]): + @abstractmethod def destroy(self) -> None: """Destroy the job. - Submit a request to kill the job. - Warnings -------- This operation is irreversible. @@ -117,112 +55,65 @@ def destroy(self) -> None: ---- This action requires administrator, owner, or collaborator privileges. """ - endpoint = self._ctx.url + self._path - self._ctx.session.delete(endpoint) -class Jobs(ActiveFinderMethods[Job], ActiveSequence[Job]): - def __init__(self, ctx: Context, path: str): - """A collection of jobs. +@runtime_checkable +class Jobs(Sized, Protocol): + @overload + def __getitem__(self, index: SupportsIndex) -> Job: ... - Parameters - ---------- - ctx : Context - The context object containing the session and URL for API interactions - path : str - The HTTP path component for the jobs endpoint (e.g., 'v1/content/544509fc-e4f0-41de-acb4-1fe3a2c1d797/jobs') + @overload + def __getitem__(self, index: slice) -> List[Job]: ... + + def find(self, key: str, /) -> Job: """ - super().__init__(ctx, path, "key") + Find a Job by its key. - def _create_instance(self, path: str, /, **attributes: Any) -> Job: - """Creates a Job instance. + Fetches the Job from Connect by it's key. Parameters ---------- - path : str - The HTTP path component for the Job resource endpoint (e.g., 'v1/content/544509fc-e4f0-41de-acb4-1fe3a2c1d797/jobs/7add0bc0-0d89-4397-ab51-90ad4bc3f5c9') + key : str + The unique identifier of the Job. Returns ------- - Job + Jobs """ - return Job(self._ctx, path, **attributes) + ... - class _FindByRequest(TypedDict, total=False): + def find_by( + self, + *, # Identifiers - id: Required[str] - """A unique identifier for the job.""" - - ppid: NotRequired[Optional[str]] - """Identifier of the parent process.""" - - pid: NotRequired[str] - """Identifier of the process running the job.""" - - key: NotRequired[str] - """A unique key to identify this job.""" - - remote_id: NotRequired[Optional[str]] - """Identifier for off-host execution configurations.""" - - app_id: NotRequired[str] - """Identifier of the parent content associated with the job.""" - - variant_id: NotRequired[str] - """Identifier of the variant responsible for the job.""" - - bundle_id: NotRequired[str] - """Identifier of the content bundle linked to the job.""" - + id: str = ..., # noqa: A002 + ppid: str | None = ..., + pid: str = ..., + key: str = ..., + remote_id: str | None = ..., + app_id: str = ..., + variant_id: str = ..., + bundle_id: str = ..., # Timestamps - start_time: NotRequired[str] - """RFC3339 timestamp indicating when the job started.""" - - end_time: NotRequired[Optional[str]] - """RFC3339 timestamp indicating when the job finished.""" - - last_heartbeat_time: NotRequired[str] - """RFC3339 timestamp of the last recorded activity for the job.""" - - queued_time: NotRequired[Optional[str]] - """RFC3339 timestamp when the job was added to the queue.""" - + start_time: str = ..., + end_time: str | None = ..., + last_heartbeat_time: str = ..., + queued_time: str | None = ..., # Status and Exit Information - status: NotRequired[Literal[0, 1, 2]] - """Current status. Options are 0 (Active), 1 (Finished), and 2 (Finalized)""" - - exit_code: NotRequired[Optional[int]] - """The job's exit code, available after completion.""" - + status: StatusCode = ..., + exit_code: int | None = ..., # Environment Information - hostname: NotRequired[str] - """Name of the node processing the job.""" - - cluster: NotRequired[Optional[str]] - """Location where the job runs, either 'Local' or the cluster name.""" - - image: NotRequired[Optional[str]] - """Location of the content in clustered environments.""" + hostname: str = ..., + cluster: str | None = ..., + image: str | None = ..., + run_as: str = ..., + queue_name: str | None = ..., + tag: JobTag = ..., + ) -> Job | None: + """Find the first record matching the specified conditions. - run_as: NotRequired[str] - """UNIX user responsible for executing the job.""" - - # Queue and Scheduling Information - queue_name: NotRequired[Optional[str]] - """Name of the queue processing the job, relevant for scheduled reports.""" - - # Job Metadata - tag: NotRequired[JobTag] - """A tag categorizing the job type. Options are build_jupyter, build_report, build_site, configure_report, git, packrat_restore, python_restore, render_shiny, run_api, run_app, run_bokeh_app, run_dash_app, run_fastapi_app, run_pyshiny_app, run_python_api, run_streamlit, run_tensorflow, run_voila_app, testing, unknown, val_py_ext_pkg, val_r_ext_pkg, and val_r_install.""" - - @overload - def find_by(self, **conditions: Unpack[_FindByRequest]) -> Optional[Job]: - """Finds the first record matching the specified conditions. + There is no implied ordering, so if order matters, you should specify it yourself. - There is no implied ordering so if order matters, you should specify it yourself. - - Parameters - ---------- id : str, not required A unique identifier for the job. ppid : Optional[str], not required @@ -266,32 +157,10 @@ def find_by(self, **conditions: Unpack[_FindByRequest]) -> Optional[Job]: Returns ------- - Optional[Job] - """ - - @overload - def find_by(self, **conditions): ... - - def find_by(self, **conditions) -> Optional[Job]: - return super().find_by(**conditions) - + Job | None -class JobsMixin(Active, Resource): - """Mixin class to add a jobs attribute to a resource.""" - - def __init__(self, ctx, path, /, **attributes): - """Mixin class which adds a `jobs` attribute to the Active Resource. - - 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 + Note + ---- + This action requires administrator, owner, or collaborator privileges. """ - super().__init__(ctx, path, **attributes) - - path = posixpath.join(path, "jobs") - self.jobs = Jobs(ctx, path) + ... diff --git a/src/posit/connect/packages.py b/src/posit/connect/packages.py index 27e24475..18975a05 100644 --- a/src/posit/connect/packages.py +++ b/src/posit/connect/packages.py @@ -1,63 +1,28 @@ from __future__ import annotations -import posixpath -from typing import Generator, Literal, Optional, TypedDict +from collections.abc import Mapping, Sized +from typing import Any, Literal, Protocol, SupportsIndex, 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 +class _ContentPackage(Mapping[str, Any]): + pass -from .resources import Active, ActiveFinderMethods, ActiveSequence +class _ContentPackages(Sized, Protocol): + @overload + def __getitem__(self, index: SupportsIndex) -> _ContentPackage: ... -class ContentPackage(Active): - class _Package(TypedDict): - language: Required[str] - name: Required[str] - version: Required[str] - hash: Required[Optional[str]] + @overload + def __getitem__(self, index: slice) -> _ContentPackage: ... - 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 + def find_by( + self, + *, + language: Literal["python", "r"] = ..., + name: str = ..., + version: str = ..., + hash: str | None = ..., # noqa: A002 + ) -> _ContentPackage | None: """ Find the first record matching the specified conditions. @@ -65,124 +30,45 @@ def find_by(self, **conditions: Unpack[_FindBy]): # type: ignore 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] + _ContentPackage | None 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.""" +class _Package(Mapping[str, Any]): + pass - 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""" +class _Packages(Sized, Protocol): + @overload + def __getitem__(self, index: SupportsIndex) -> _ContentPackage: ... - app_guid: Required[str] - """The unique identifier of the application this package is associated with""" + @overload + def __getitem__(self, index: slice) -> _ContentPackage: ... - 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 + def find_by( + self, + *, + language: Literal["python", "r"] = ..., + name: str = ..., + version: str = ..., + hash: str | None = ..., # noqa: A002, + bundle_id: str = ..., + app_id: str = ..., + app_guid: str = ..., + ) -> _ContentPackage | None: """ Find the first record matching the specified conditions. @@ -190,24 +76,24 @@ def find_by(self, **conditions: Unpack[_FindBy]) -> "Package | None": # type: i 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. + bundle_id: str, not required + The unique identifier of the bundle this package is associated with. + app_id: str, not required + The numerical identifier of the application this package is associated with. + app_guid: str, not required + The unique identifier of the application this package is associated with. Returns ------- - Optional[Package] + _Package | None 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/resources.py b/src/posit/connect/resources.py index e607c599..dc843273 100644 --- a/src/posit/connect/resources.py +++ b/src/posit/connect/resources.py @@ -2,21 +2,18 @@ import posixpath import warnings -from abc import ABC, abstractmethod +from abc import ABC from dataclasses import dataclass from typing import ( TYPE_CHECKING, Any, - Generic, Iterable, - List, - Optional, Sequence, - TypeVar, - overload, ) -from typing_extensions import Self +from posit.connect.paginator import Paginator + +from .context import Context if TYPE_CHECKING: import requests @@ -88,154 +85,6 @@ def __init__(self, ctx: Context, path: str, /, **attributes): self._path = path -T = TypeVar("T", bound="Active") -"""A type variable that is bound to the `Active` class""" - - -class ActiveSequence(ABC, Generic[T], Sequence[T]): - """A sequence for any HTTP GET endpoint that returns a collection.""" - - _cache: Optional[List[T]] - - def __init__(self, ctx: Context, path: str, uid: str = "guid"): - """A sequence abstraction for any HTTP GET endpoint that returns a collection. - - Parameters - ---------- - ctx : Context - The context object containing the session and URL for API interactions. - path : str - The HTTP path component for the collection endpoint - uid : str, optional - The field name of that uniquely identifiers an instance of T, by default "guid" - """ - super().__init__() - 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: - """Create an instance of 'T'.""" - raise NotImplementedError() - - def fetch(self, **conditions: Any) -> Iterable[T]: - """Fetch the collection. - - Fetches the collection directly from Connect. This operation does not effect the cache state. - - Returns - ------- - List[T] - """ - endpoint = self._ctx.url + self._path - response = self._ctx.session.get(endpoint, params=conditions) - 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): - return self._data[index] - - def __len__(self) -> int: - return len(self._data) - - def __iter__(self): - return iter(self._data) - - def __str__(self) -> str: - return str(self._data) - - def __repr__(self) -> str: - return repr(self._data) - - -class ActiveFinderMethods(ActiveSequence[T]): - """Finder methods. - - Provides various finder methods for locating records in any endpoint supporting HTTP GET requests. - """ - - def find(self, uid) -> T: - """ - Find a record by its unique identifier. - - Fetches the record from Connect by it's identifier. - - Parameters - ---------- - uid : Any - The unique identifier of the record. - - Returns - ------- - T - """ - endpoint = self._ctx.url + self._path + uid - response = self._ctx.session.get(endpoint) - result = response.json() - return self._to_instance(result) - - def find_by(self, **conditions: Any) -> T | None: - """ - Find the first record matching the specified conditions. - - There is no implied ordering, so if order matters, you should specify it yourself. - - Parameters - ---------- - **conditions : Any - - Returns - ------- - Optional[T] - The first record matching the conditions, or `None` if no match is found. - """ - collection = self.fetch(**conditions) - return next((v for v in collection if v.items() >= conditions.items()), None) - - class _Resource(dict): def __init__(self, ctx: Context, path: str, **attributes): self._ctx = ctx @@ -258,10 +107,10 @@ def __init__(self, ctx: Context, path: str, *, uid: str = "guid"): self._uid = uid def __getitem__(self, index): - return self.fetch()[index] + return list(self.fetch())[index] def __len__(self) -> int: - return len(self.fetch()) + return len(list(self.fetch())) def __iter__(self): return iter(self.fetch()) @@ -279,7 +128,7 @@ def create(self, **attributes: Any) -> Any: path = posixpath.join(self._path, uid) return _Resource(self._ctx, path, **result) - def fetch(self, **conditions) -> List[Any]: + def fetch(self, **conditions) -> Iterable[Any]: response = self._ctx.client.get(self._path, params=conditions) results = response.json() resources = [] @@ -314,3 +163,18 @@ def find_by(self, **conditions) -> Any | None: """ collection = self.fetch(**conditions) return next((v for v in collection if v.items() >= conditions.items()), None) + + +class _PaginatedResourceSequence(_ResourceSequence): + def fetch(self, **conditions): + url = self._ctx.url + self._path + paginator = Paginator(self._ctx.session, url, dict(**conditions)) + for page in paginator.fetch_pages(): + resources = [] + results = page.results + for result in results: + uid = result[self._uid] + path = posixpath.join(self._path, uid) + resource = _Resource(self._ctx, path, **result) + resources.append(resource) + yield from resources diff --git a/tests/posit/connect/test_jobs.py b/tests/posit/connect/test_jobs.py index d97df373..b2463d98 100644 --- a/tests/posit/connect/test_jobs.py +++ b/tests/posit/connect/test_jobs.py @@ -1,3 +1,5 @@ +from typing import TYPE_CHECKING + import pytest import responses from requests.exceptions import HTTPError @@ -6,6 +8,9 @@ from .api import load_mock +if TYPE_CHECKING: + from posit.connect.jobs import Job, Jobs + class TestJobsMixin: @responses.activate @@ -22,8 +27,8 @@ def test(self): c = Client("https://connect.example", "12345") content = c.content.get("f2f37341-e21d-3d80-c698-a935ad614066") - - assert len(content.jobs) == 1 + jobs: Jobs = content.jobs + assert len(jobs) == 1 class TestJobsFind: @@ -44,7 +49,7 @@ def test(self): c = Client("https://connect.example", "12345") content = c.content.get("f2f37341-e21d-3d80-c698-a935ad614066") - job = content.jobs.find("tHawGvHZTosJA2Dx") + job: Job = content.jobs.find("tHawGvHZTosJA2Dx") assert job["key"] == "tHawGvHZTosJA2Dx" @responses.activate diff --git a/tests/posit/connect/test_packages.py b/tests/posit/connect/test_packages.py index 4d42f535..28ac75f4 100644 --- a/tests/posit/connect/test_packages.py +++ b/tests/posit/connect/test_packages.py @@ -1,4 +1,3 @@ -import pytest import responses from posit.connect.client import Client @@ -6,36 +5,6 @@ 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):