From dbafa68bb003885ccbcc2032fb13813154e48d3d Mon Sep 17 00:00:00 2001 From: Taylor Steinberg Date: Tue, 17 Dec 2024 10:34:12 -0500 Subject: [PATCH] refactor: add resource protocols (#364) Adds protocols `Resource` and `ResourceSequence`, which define the base interface for any resource type (e.g., `Job`, `Jobs`, `Package`, `Packages`.) Co-authored-by: Barret Schloerke --- src/posit/connect/bundles.py | 4 +-- src/posit/connect/content.py | 10 +++--- src/posit/connect/environments.py | 19 +++-------- src/posit/connect/groups.py | 4 +-- src/posit/connect/jobs.py | 19 +++-------- src/posit/connect/metrics/shiny_usage.py | 4 +-- src/posit/connect/metrics/usage.py | 2 +- src/posit/connect/metrics/visits.py | 4 +-- src/posit/connect/oauth/associations.py | 4 +-- src/posit/connect/oauth/integrations.py | 4 +-- src/posit/connect/oauth/sessions.py | 4 +-- src/posit/connect/packages.py | 27 ++++------------ src/posit/connect/permissions.py | 4 +-- src/posit/connect/resources.py | 40 +++++++++++++++++++++--- src/posit/connect/tasks.py | 2 +- src/posit/connect/users.py | 4 +-- src/posit/connect/vanities.py | 6 ++-- src/posit/connect/variants.py | 4 +-- tests/posit/connect/test_resources.py | 4 +-- 19 files changed, 82 insertions(+), 87 deletions(-) diff --git a/src/posit/connect/bundles.py b/src/posit/connect/bundles.py index 515751fa..3363e3bb 100644 --- a/src/posit/connect/bundles.py +++ b/src/posit/connect/bundles.py @@ -11,11 +11,11 @@ from .context import Context -class BundleMetadata(resources.Resource): +class BundleMetadata(resources.BaseResource): pass -class Bundle(resources.Resource): +class Bundle(resources.BaseResource): @property def metadata(self) -> BundleMetadata: return BundleMetadata(self._ctx, **self.get("metadata", {})) diff --git a/src/posit/connect/content.py b/src/posit/connect/content.py index a98e9a64..dd900f6d 100644 --- a/src/posit/connect/content.py +++ b/src/posit/connect/content.py @@ -24,7 +24,7 @@ from .errors import ClientError from .oauth.associations import ContentItemAssociations from .permissions import Permissions -from .resources import Active, Resource, Resources, _ResourceSequence +from .resources import Active, BaseResource, Resources, _ResourceSequence from .tags import ContentItemTags from .vanities import VanityMixin from .variants import Variants @@ -160,7 +160,7 @@ def update( ) -class ContentItemOAuth(Resource): +class ContentItemOAuth(BaseResource): def __init__(self, ctx: Context, content_guid: str) -> None: super().__init__(ctx) self["content_guid"] = content_guid @@ -170,11 +170,11 @@ def associations(self) -> ContentItemAssociations: return ContentItemAssociations(self._ctx, content_guid=self["content_guid"]) -class ContentItemOwner(Resource): +class ContentItemOwner(BaseResource): pass -class ContentItem(Active, VanityMixin, Resource): +class ContentItem(Active, VanityMixin, BaseResource): class _AttrsBase(TypedDict, total=False): # # `name` will be set by other _Attrs classes # name: str @@ -376,7 +376,7 @@ def restart(self) -> None: f"Restart not supported for this application mode: {self['app_mode']}. Did you need to use the 'render()' method instead? Note that some application modes do not support 'render()' or 'restart()'.", ) - def update( + def update( # type: ignore[reportIncompatibleMethodOverride] self, **attrs: Unpack[ContentItem._Attrs], ) -> None: diff --git a/src/posit/connect/environments.py b/src/posit/connect/environments.py index 4b229fa9..d2cc85e5 100644 --- a/src/posit/connect/environments.py +++ b/src/posit/connect/environments.py @@ -1,19 +1,17 @@ from __future__ import annotations from abc import abstractmethod -from collections.abc import Mapping, Sized +from typing import Protocol from typing_extensions import ( - Any, List, Literal, - Protocol, - SupportsIndex, TypedDict, - overload, runtime_checkable, ) +from .resources import Resource, ResourceSequence + MatchingType = Literal["any", "exact", "none"] """Directions for how environments are considered for selection. @@ -40,7 +38,7 @@ class Installations(TypedDict): """Interpreter installations in an execution environment.""" -class Environment(Mapping[str, Any]): +class Environment(Resource): @abstractmethod def destroy(self) -> None: """Destroy the environment. @@ -95,13 +93,7 @@ def update( @runtime_checkable -class Environments(Sized, Protocol): - @overload - def __getitem__(self, index: SupportsIndex) -> Environment: ... - - @overload - def __getitem__(self, index: slice) -> List[Environment]: ... - +class Environments(ResourceSequence[Environment], Protocol): def create( self, *, @@ -217,4 +209,3 @@ def find_by( ---- This action requires administrator or publisher privileges. """ - ... diff --git a/src/posit/connect/groups.py b/src/posit/connect/groups.py index 717b18d9..fa5cf9dd 100644 --- a/src/posit/connect/groups.py +++ b/src/posit/connect/groups.py @@ -5,7 +5,7 @@ from typing import TYPE_CHECKING, List, Optional, overload from .paginator import Paginator -from .resources import Resource, Resources +from .resources import BaseResource, Resources if TYPE_CHECKING: import requests @@ -14,7 +14,7 @@ from .users import User -class Group(Resource): +class Group(BaseResource): def __init__(self, ctx: Context, **kwargs) -> None: super().__init__(ctx, **kwargs) self._ctx: Context = ctx diff --git a/src/posit/connect/jobs.py b/src/posit/connect/jobs.py index d9124f82..8797686b 100644 --- a/src/posit/connect/jobs.py +++ b/src/posit/connect/jobs.py @@ -1,18 +1,14 @@ from __future__ import annotations -from abc import abstractmethod -from collections.abc import Mapping, Sized from typing import ( - Any, Iterable, - List, Literal, Protocol, - SupportsIndex, - overload, runtime_checkable, ) +from .resources import Resource, ResourceSequence + JobTag = Literal[ "unknown", "build_report", @@ -43,8 +39,7 @@ StatusCode = Literal[0, 1, 2] -class Job(Mapping[str, Any]): - @abstractmethod +class Job(Resource, Protocol): def destroy(self) -> None: """Destroy the job. @@ -59,13 +54,7 @@ def destroy(self) -> None: @runtime_checkable -class Jobs(Sized, Protocol): - @overload - def __getitem__(self, index: SupportsIndex) -> Job: ... - - @overload - def __getitem__(self, index: slice) -> List[Job]: ... - +class Jobs(ResourceSequence[Job], Protocol): def fetch(self) -> Iterable[Job]: """Fetch all jobs. diff --git a/src/posit/connect/metrics/shiny_usage.py b/src/posit/connect/metrics/shiny_usage.py index 68a391be..a5240f4f 100644 --- a/src/posit/connect/metrics/shiny_usage.py +++ b/src/posit/connect/metrics/shiny_usage.py @@ -3,10 +3,10 @@ from typing import List, overload from ..cursors import CursorPaginator -from ..resources import Resource, Resources +from ..resources import BaseResource, Resources -class ShinyUsageEvent(Resource): +class ShinyUsageEvent(BaseResource): @property def content_guid(self) -> str: """The associated unique content identifier. diff --git a/src/posit/connect/metrics/usage.py b/src/posit/connect/metrics/usage.py index 6714cca7..b9eac649 100644 --- a/src/posit/connect/metrics/usage.py +++ b/src/posit/connect/metrics/usage.py @@ -10,7 +10,7 @@ from . import shiny_usage, visits -class UsageEvent(resources.Resource): +class UsageEvent(resources.BaseResource): @staticmethod def from_event( event: visits.VisitEvent | shiny_usage.ShinyUsageEvent, diff --git a/src/posit/connect/metrics/visits.py b/src/posit/connect/metrics/visits.py index 393aae36..ea88dfb6 100644 --- a/src/posit/connect/metrics/visits.py +++ b/src/posit/connect/metrics/visits.py @@ -3,10 +3,10 @@ from typing import List, overload from ..cursors import CursorPaginator -from ..resources import Resource, Resources +from ..resources import BaseResource, Resources -class VisitEvent(Resource): +class VisitEvent(BaseResource): @property def content_guid(self) -> str: """The associated unique content identifier. diff --git a/src/posit/connect/oauth/associations.py b/src/posit/connect/oauth/associations.py index 9fc999e6..44723dfc 100644 --- a/src/posit/connect/oauth/associations.py +++ b/src/posit/connect/oauth/associations.py @@ -3,10 +3,10 @@ from typing import List from ..context import Context -from ..resources import Resource, Resources +from ..resources import BaseResource, Resources -class Association(Resource): +class Association(BaseResource): pass diff --git a/src/posit/connect/oauth/integrations.py b/src/posit/connect/oauth/integrations.py index 540cd6f0..3e3259e9 100644 --- a/src/posit/connect/oauth/integrations.py +++ b/src/posit/connect/oauth/integrations.py @@ -2,11 +2,11 @@ from typing import List, Optional, overload -from ..resources import Resource, Resources +from ..resources import BaseResource, Resources from .associations import IntegrationAssociations -class Integration(Resource): +class Integration(BaseResource): """OAuth integration resource.""" @property diff --git a/src/posit/connect/oauth/sessions.py b/src/posit/connect/oauth/sessions.py index 503ea254..fae3981b 100644 --- a/src/posit/connect/oauth/sessions.py +++ b/src/posit/connect/oauth/sessions.py @@ -2,10 +2,10 @@ from typing import List, Optional, overload -from ..resources import Resource, Resources +from ..resources import BaseResource, Resources -class Session(Resource): +class Session(BaseResource): """OAuth session resource.""" def delete(self) -> None: diff --git a/src/posit/connect/packages.py b/src/posit/connect/packages.py index 63289c4f..4983eaff 100644 --- a/src/posit/connect/packages.py +++ b/src/posit/connect/packages.py @@ -1,28 +1,19 @@ from __future__ import annotations from typing_extensions import ( - Any, Iterable, Literal, - Mapping, Protocol, - Sized, - SupportsIndex, - overload, ) - -class ContentPackage(Mapping[str, Any]): - pass +from .resources import Resource, ResourceSequence -class ContentPackages(Sized, Protocol): - @overload - def __getitem__(self, index: SupportsIndex) -> ContentPackage: ... +class ContentPackage(Resource, Protocol): + pass - @overload - def __getitem__(self, index: slice) -> ContentPackage: ... +class ContentPackages(ResourceSequence[ContentPackage], Protocol): def fetch( self, *, @@ -84,17 +75,11 @@ def find_by( ... -class Package(Mapping[str, Any]): +class Package(Resource, Protocol): pass -class Packages(Sized, Protocol): - @overload - def __getitem__(self, index: SupportsIndex) -> ContentPackage: ... - - @overload - def __getitem__(self, index: slice) -> ContentPackage: ... - +class Packages(ResourceSequence[Package], Protocol): def fetch( self, *, diff --git a/src/posit/connect/permissions.py b/src/posit/connect/permissions.py index e8afd9c1..211decc2 100644 --- a/src/posit/connect/permissions.py +++ b/src/posit/connect/permissions.py @@ -6,7 +6,7 @@ from requests.sessions import Session as Session -from .resources import Resource, Resources +from .resources import BaseResource, Resources if TYPE_CHECKING: from .context import Context @@ -14,7 +14,7 @@ from .users import User -class Permission(Resource): +class Permission(BaseResource): def destroy(self) -> None: """Destroy the permission.""" path = f"v1/content/{self['content_guid']}/permissions/{self['id']}" diff --git a/src/posit/connect/resources.py b/src/posit/connect/resources.py index 66e6058f..d6650e07 100644 --- a/src/posit/connect/resources.py +++ b/src/posit/connect/resources.py @@ -6,8 +6,15 @@ from typing import ( TYPE_CHECKING, Any, + Hashable, Iterable, + Iterator, + List, + Protocol, Sequence, + SupportsIndex, + TypeVar, + overload, ) from .context import Context @@ -17,7 +24,7 @@ from .context import Context -class Resource(dict): +class BaseResource(dict): def __init__(self, ctx: Context, /, **kwargs): super().__init__(**kwargs) self._ctx = ctx @@ -42,7 +49,7 @@ def __init__(self, ctx: Context) -> None: self._ctx = ctx -class Active(ABC, Resource): +class Active(ABC, BaseResource): def __init__(self, ctx: Context, path: str, /, **attributes): """A dict abstraction for any HTTP endpoint that returns a singular resource. @@ -62,7 +69,11 @@ def __init__(self, ctx: Context, path: str, /, **attributes): self._path = path -class _Resource(dict): +class Resource(Protocol): + def __getitem__(self, key: Hashable, /) -> Any: ... + + +class _Resource(dict, Resource): def __init__(self, ctx: Context, path: str, **attributes): self._ctx = ctx self._path = path @@ -77,7 +88,26 @@ def update(self, **attributes): # type: ignore[reportIncompatibleMethodOverride super().update(**result) -class _ResourceSequence(Sequence): +T = TypeVar("T", bound=Resource) + + +class ResourceSequence(Protocol[T]): + @overload + def __getitem__(self, index: SupportsIndex, /) -> T: ... + + @overload + def __getitem__(self, index: slice, /) -> List[T]: ... + + def __len__(self) -> int: ... + + def __iter__(self) -> Iterator[T]: ... + + def __str__(self) -> str: ... + + def __repr__(self) -> str: ... + + +class _ResourceSequence(Sequence[T], ResourceSequence[T]): def __init__(self, ctx: Context, path: str, *, uid: str = "guid"): self._ctx = ctx self._path = path @@ -89,7 +119,7 @@ def __getitem__(self, index): def __len__(self) -> int: return len(list(self.fetch())) - def __iter__(self): + def __iter__(self) -> Iterator[T]: return iter(self.fetch()) def __str__(self) -> str: diff --git a/src/posit/connect/tasks.py b/src/posit/connect/tasks.py index 907a458f..8360304d 100644 --- a/src/posit/connect/tasks.py +++ b/src/posit/connect/tasks.py @@ -7,7 +7,7 @@ from . import resources -class Task(resources.Resource): +class Task(resources.BaseResource): @property def is_finished(self) -> bool: """The task state. diff --git a/src/posit/connect/users.py b/src/posit/connect/users.py index f002cf88..334617a5 100644 --- a/src/posit/connect/users.py +++ b/src/posit/connect/users.py @@ -9,14 +9,14 @@ from . import me from .content import Content from .paginator import Paginator -from .resources import Resource, Resources +from .resources import BaseResource, Resources if TYPE_CHECKING: from .context import Context from .groups import Group -class User(Resource): +class User(BaseResource): @property def content(self) -> Content: return Content(self._ctx, owner_guid=self["guid"]) diff --git a/src/posit/connect/vanities.py b/src/posit/connect/vanities.py index 75becc6f..5a483054 100644 --- a/src/posit/connect/vanities.py +++ b/src/posit/connect/vanities.py @@ -4,10 +4,10 @@ from .context import Context from .errors import ClientError -from .resources import Resource, Resources +from .resources import BaseResource, Resources -class Vanity(Resource): +class Vanity(BaseResource): """A vanity resource. Vanities maintain custom URL paths assigned to content. @@ -115,7 +115,7 @@ def all(self) -> List[Vanity]: return [Vanity(self._ctx, **result) for result in results] -class VanityMixin(Resource): +class VanityMixin(BaseResource): """Mixin class to add a vanity attribute to a resource.""" class HasGuid(TypedDict): diff --git a/src/posit/connect/variants.py b/src/posit/connect/variants.py index ba8597c1..9970c70e 100644 --- a/src/posit/connect/variants.py +++ b/src/posit/connect/variants.py @@ -1,11 +1,11 @@ from typing import List from .context import Context -from .resources import Resource, Resources +from .resources import BaseResource, Resources from .tasks import Task -class Variant(Resource): +class Variant(BaseResource): def render(self) -> Task: path = f"variants/{self['id']}/render" response = self._ctx.client.post(path) diff --git a/tests/posit/connect/test_resources.py b/tests/posit/connect/test_resources.py index 28cf6e0e..a17d252b 100644 --- a/tests/posit/connect/test_resources.py +++ b/tests/posit/connect/test_resources.py @@ -3,13 +3,13 @@ from unittest import mock from unittest.mock import Mock -from posit.connect.resources import Resource +from posit.connect.resources import BaseResource config = Mock() session = Mock() -class FakeResource(Resource): +class FakeResource(BaseResource): @property def foo(self) -> Optional[str]: return self.get("foo")