From 0533f19d463baba13f9b3ef29baa86a8ab505023 Mon Sep 17 00:00:00 2001 From: tdstein Date: Fri, 18 Oct 2024 09:07:41 -0400 Subject: [PATCH 01/31] feat: add jobs --- src/posit/connect/content.py | 3 +- src/posit/connect/jobs.py | 73 ++++++++++++++++++++++++++++++++++ src/posit/connect/paginator.py | 2 +- 3 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 src/posit/connect/jobs.py diff --git a/src/posit/connect/content.py b/src/posit/connect/content.py index bd0f4ede..7b1b2087 100644 --- a/src/posit/connect/content.py +++ b/src/posit/connect/content.py @@ -10,6 +10,7 @@ from . import tasks from .bundles import Bundles from .env import EnvVars +from .jobs import JobsMixin from .oauth.associations import ContentItemAssociations from .permissions import Permissions from .resources import Resource, ResourceParameters, Resources @@ -32,7 +33,7 @@ class ContentItemOwner(Resource): pass -class ContentItem(VanityMixin, Resource): +class ContentItem(JobsMixin, VanityMixin, Resource): def __getitem__(self, key: Any) -> Any: v = super().__getitem__(key) if key == "owner" and isinstance(v, dict): diff --git a/src/posit/connect/jobs.py b/src/posit/connect/jobs.py new file mode 100644 index 00000000..8a6d63f8 --- /dev/null +++ b/src/posit/connect/jobs.py @@ -0,0 +1,73 @@ +from typing import List, Sequence, TypedDict + +from typing_extensions import Required, Unpack + +from .resources import Resource, ResourceParameters, Resources + + +class Job(Resource): + pass + + +class Jobs(Resources, Sequence[Job]): + """A collection of jobs.""" + + def __init__(self, params, endpoint): + super().__init__(params) + self._endpoint = endpoint + self._cache = None + + @property + def _data(self) -> List[Job]: + if self._cache: + return self._cache + + response = self.params.session.get(self._endpoint) + results = response.json() + self._cache = [Job(self.params, **result) for result in results] + return self._cache + + def __getitem__(self, index): + """Retrieve an item or slice from the sequence.""" + return self._data[index] + + def __len__(self): + """Return the length of the sequence.""" + return len(self._data) + + def __repr__(self): + """Return the string representation of the sequence.""" + return f"Jobs({', '.join(map(str, self._data))})" + + def count(self, value): + """Return the number of occurrences of a value in the sequence.""" + return self._data.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._data) + return self._data.index(value, start, stop) + + def reload(self) -> "Jobs": + """Unload the cached jobs. + + Forces the next access, if any, to query the jobs from the Connect server. + """ + self._cache = None + return self + + +class JobsMixin(Resource): + """Mixin class to add a jobs attribute to a resource.""" + + class HasGuid(TypedDict): + """Has a guid.""" + + guid: Required[str] + + def __init__(self, params: ResourceParameters, **kwargs: Unpack[HasGuid]): + super().__init__(params, **kwargs) + uid = kwargs["guid"] + endpoint = self.params.url + f"v1/content/{uid}/jobs" + self.jobs = Jobs(self.params, endpoint) diff --git a/src/posit/connect/paginator.py b/src/posit/connect/paginator.py index a85c5e64..2308e42c 100644 --- a/src/posit/connect/paginator.py +++ b/src/posit/connect/paginator.py @@ -38,7 +38,7 @@ class Paginator: url (str): The URL of the paginated API endpoint. """ - def __init__(self, session: requests.Session, url: str, params = {}) -> None: + def __init__(self, session: requests.Session, url: str, params={}) -> None: self.session = session self.url = url self.params = params From 6b79912312f1754bdee6fa373d1b9559d76ee130 Mon Sep 17 00:00:00 2001 From: tdstein Date: Tue, 22 Oct 2024 11:05:50 -0400 Subject: [PATCH 02/31] --wip-- [skip ci] --- src/posit/connect/context.py | 4 +- src/posit/connect/jobs.py | 267 +++++++++++++++++++++++++++- src/posit/connect/resources.py | 38 ++++ src/posit/connect/vanities.py | 2 +- tests/posit/connect/test_content.py | 191 +------------------- 5 files changed, 302 insertions(+), 200 deletions(-) diff --git a/src/posit/connect/context.py b/src/posit/connect/context.py index c93fe7b0..8fbbe48c 100644 --- a/src/posit/connect/context.py +++ b/src/posit/connect/context.py @@ -4,6 +4,8 @@ import requests from packaging.version import Version +from .urls import Url + def requires(version: str): def decorator(func): @@ -22,7 +24,7 @@ def wrapper(instance: ContextManager, *args, **kwargs): class Context(dict): - def __init__(self, session: requests.Session, url: str): + def __init__(self, session: requests.Session, url: Url): self.session = session self.url = url diff --git a/src/posit/connect/jobs.py b/src/posit/connect/jobs.py index 8a6d63f8..d7ebf71e 100644 --- a/src/posit/connect/jobs.py +++ b/src/posit/connect/jobs.py @@ -1,20 +1,132 @@ -from typing import List, Sequence, TypedDict +from typing import List, Literal, Optional, Sequence, TypedDict, overload -from typing_extensions import Required, Unpack +from typing_extensions import NotRequired, Required, Unpack -from .resources import Resource, ResourceParameters, Resources +from .errors import ClientError +from .resources import FinderMethods, Resource, ResourceParameters, Resources + +JobTag = Literal[ + "unknown", + "build_report", + "build_site", + "build_jupyter", + "packrat_restore", + "python_restore", + "configure_report", + "run_app", + "run_api", + "run_tensorflow", + "run_python_api", + "run_dash_app", + "run_streamlit", + "run_bokeh_app", + "run_fastapi_app", + "run_pyshiny_app", + "render_shiny", + "run_voila_app", + "testing", + "git", + "val_py_ext_pkg", + "val_r_ext_pkg", + "val_r_install", +] class Job(Resource): - pass + 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.""" -class Jobs(Resources, Sequence[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.""" + + 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, /, params, endpoint, **kwargs: Unpack[_Job]): + super().__init__(params, **kwargs) + key = kwargs["key"] + self._endpoint = endpoint + key + + def destroy(self) -> None: + """Destroy the job. + + Submit a request to kill the job. + + Warnings + -------- + This operation is irreversible. + + Note + ---- + This action requires administrator, owner, or collaborator privileges. + """ + self.params.session.delete(self._endpoint) + + +class Jobs(FinderMethods[Job], Sequence[Job], Resources): """A collection of jobs.""" def __init__(self, params, endpoint): - super().__init__(params) - self._endpoint = endpoint + super().__init__(Job, params, endpoint) + self._endpoint = endpoint + "jobs" self._cache = None @property @@ -24,7 +136,7 @@ def _data(self) -> List[Job]: response = self.params.session.get(self._endpoint) results = response.json() - self._cache = [Job(self.params, **result) for result in results] + self._cache = [Job(self.params, self._endpoint, **result) for result in results] return self._cache def __getitem__(self, index): @@ -49,6 +161,143 @@ def index(self, value, start=0, stop=None): stop = len(self._data) return self._data.index(value, start, stop) + class _FindByRequest(TypedDict, total=False): + # Identifiers + id: NotRequired[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.""" + + # 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.""" + + # 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.""" + + # 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.""" + + 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. + + Parameters + ---------- + id : str, not required + A unique identifier for the job. + ppid : Optional[str], not required + Identifier of the parent process. + pid : str, not required + Identifier of the process running the job. + key : str, not required + A unique key to identify this job. + remote_id : Optional[str], not required + Identifier for off-host execution configurations. + app_id : str, not required + Identifier of the parent content associated with the job. + variant_id : str, not required + Identifier of the variant responsible for the job. + bundle_id : str, not required + Identifier of the content bundle linked to the job. + start_time : str, not required + RFC3339 timestamp indicating when the job started. + end_time : Optional[str], not required + RFC3339 timestamp indicating when the job finished. + last_heartbeat_time : str, not required + RFC3339 timestamp of the last recorded activity for the job. + queued_time : Optional[str], not required + RFC3339 timestamp when the job was added to the queue. + status : int, not required + Current status. Options are 0 (Active), 1 (Finished), and 2 (Finalized) + exit_code : Optional[int], not required + The job's exit code, available after completion. + hostname : str, not required + Name of the node processing the job. + cluster : Optional[str], not required + Location where the job runs, either 'Local' or the cluster name. + image : Optional[str], not required + Location of the content in clustered environments. + run_as : str, not required + UNIX user responsible for executing the job. + queue_name : Optional[str], not required + Name of the queue processing the job, relevant for scheduled reports. + tag : JobTag, not required + 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. + + Returns + ------- + Optional[Job] + """ + ... + + @overload + def find_by(self, **conditions): ... + + def find_by(self, **conditions): + if "key" in conditions and self._cache is None: + key = conditions["key"] + try: + return self.find(key) + except ClientError as e: + if e.http_status == 404: + return None + raise e + + return super().find_by(**conditions) + def reload(self) -> "Jobs": """Unload the cached jobs. @@ -69,5 +318,5 @@ class HasGuid(TypedDict): def __init__(self, params: ResourceParameters, **kwargs: Unpack[HasGuid]): super().__init__(params, **kwargs) uid = kwargs["guid"] - endpoint = self.params.url + f"v1/content/{uid}/jobs" + endpoint = self.params.url + f"v1/content/{uid}" self.jobs = Jobs(self.params, endpoint) diff --git a/src/posit/connect/resources.py b/src/posit/connect/resources.py index be1ef7b7..46fcd207 100644 --- a/src/posit/connect/resources.py +++ b/src/posit/connect/resources.py @@ -1,5 +1,7 @@ import warnings +from abc import ABC, abstractmethod from dataclasses import dataclass +from typing import Any, Generic, List, Optional, Type, TypeVar import requests @@ -43,3 +45,39 @@ def update(self, *args, **kwargs): class Resources: def __init__(self, params: ResourceParameters) -> None: self.params = params + + +T = TypeVar("T", bound=Resource) + + +class FinderMethods( + Generic[T], + ABC, + Resources, +): + def __init__(self, cls: Type[T], params, endpoint): + super().__init__(params) + self._cls = cls + self._endpoint = endpoint + + @property + @abstractmethod + def _data(self) -> List[T]: + raise NotImplementedError() + + def find(self, uid): + endpoint = self._endpoint + str(uid) + response = self.params.session.get(endpoint) + result = response.json() + return self._cls(self.params, endpoint=self._endpoint, **result) + + def find_by(self, **conditions: Any) -> Optional[T]: + """Finds the first record matching the specified conditions. + + There is no implied ordering so if order matters, you should specify it yourself. + + Returns + ------- + Optional[T] + """ + return next((v for v in self._data if v.items() >= conditions.items()), None) diff --git a/src/posit/connect/vanities.py b/src/posit/connect/vanities.py index a13d0282..571dccef 100644 --- a/src/posit/connect/vanities.py +++ b/src/posit/connect/vanities.py @@ -201,7 +201,7 @@ class CreateVanityRequest(TypedDict, total=False): """A request schema for creating a vanity.""" path: Required[str] - """The vanity path (.e.g, 'my-dashboard')""" + """The vanity path (e.g., 'my-dashboard')""" force: NotRequired[bool] """Whether to force creation of the vanity""" diff --git a/tests/posit/connect/test_content.py b/tests/posit/connect/test_content.py index ea857581..e18d242c 100644 --- a/tests/posit/connect/test_content.py +++ b/tests/posit/connect/test_content.py @@ -1,199 +1,12 @@ -from unittest import mock - import pytest import responses from responses import matchers from posit.connect.client import Client -from posit.connect.content import ( - ContentItem, - ContentItemOAuth, - ContentItemOwner, -) -from posit.connect.oauth.associations import ContentItemAssociations -from posit.connect.permissions import Permissions from .api import load_mock # type: ignore -class TestContentOwnerAttributes: - @classmethod - def setup_class(cls): - guid = "20a79ce3-6e87-4522-9faf-be24228800a4" - fake_item = load_mock(f"v1/users/{guid}.json") - cls.item = ContentItemOwner(mock.Mock(), **fake_item) - - def test_guid(self): - assert self.item.guid == "20a79ce3-6e87-4522-9faf-be24228800a4" - - def test_username(self): - assert self.item.username == "carlos12" - - def test_first_name(self): - assert self.item.first_name == "Carlos" - - def test_last_name(self): - assert self.item.last_name == "User" - - -class TestContentItemAttributes: - @classmethod - def setup_class(cls): - guid = "f2f37341-e21d-3d80-c698-a935ad614066" - fake_item = load_mock(f"v1/content/{guid}.json") - cls.item = ContentItem(mock.Mock(), **fake_item) - - def test_id(self): - assert self.item.id == "8274" - - def test_guid(self): - assert self.item.guid == "f2f37341-e21d-3d80-c698-a935ad614066" - - def test_name(self): - assert self.item.name == "Performance-Data-1671216053560" - - def test_title(self): - assert self.item.title == "Performance Data" - - def test_description(self): - assert self.item.description == "" - - def test_access_type(self): - assert self.item.access_type == "logged_in" - - def test_connection_timeout(self): - assert self.item.connection_timeout is None - - def test_read_timeout(self): - assert self.item.read_timeout is None - - def test_init_timeout(self): - assert self.item.init_timeout is None - - def test_idle_timeout(self): - assert self.item.idle_timeout is None - - def test_max_processes(self): - assert self.item.max_processes is None - - def test_min_processes(self): - assert self.item.min_processes is None - - def test_max_conns_per_process(self): - assert self.item.max_conns_per_process is None - - def test_load_factor(self): - assert self.item.load_factor is None - - def test_cpu_request(self): - assert self.item.cpu_request is None - - def test_cpu_limit(self): - assert self.item.cpu_limit is None - - def test_memory_request(self): - assert self.item.memory_request is None - - def test_memory_limit(self): - assert self.item.memory_limit is None - - def test_amd_gpu_limit(self): - assert self.item.amd_gpu_limit is None - - def test_nvidia_gpu_limit(self): - assert self.item.nvidia_gpu_limit is None - - def test_created_time(self): - assert self.item.created_time == "2022-12-16T18:40:53Z" - - def test_last_deployed_time(self): - assert self.item.last_deployed_time == "2024-02-24T09:56:30Z" - - def test_bundle_id(self): - assert self.item.bundle_id == "401171" - - def test_app_mode(self): - assert self.item.app_mode == "quarto-static" - - def test_content_category(self): - assert self.item.content_category == "" - - def test_parameterized(self): - assert self.item.parameterized is False - - def test_cluster_name(self): - assert self.item.cluster_name == "Local" - - def test_image_name(self): - assert self.item.image_name is None - - def test_default_image_name(self): - assert self.item.default_image_name is None - - def test_default_r_environment_management(self): - assert self.item.default_r_environment_management is None - - def test_default_py_environment_management(self): - assert self.item.default_py_environment_management is None - - def test_service_account_name(self): - assert self.item.service_account_name is None - - def test_r_version(self): - assert self.item.r_version is None - - def test_r_environment_management(self): - assert self.item.r_environment_management is None - - def test_py_version(self): - assert self.item.py_version == "3.9.17" - - def test_py_environment_management(self): - assert self.item.py_environment_management is True - - def test_quarto_version(self): - assert self.item.quarto_version == "1.3.340" - - def test_run_as(self): - assert self.item.run_as is None - - def test_run_as_current_user(self): - assert self.item.run_as_current_user is False - - def test_owner_guid(self): - assert self.item.owner_guid == "20a79ce3-6e87-4522-9faf-be24228800a4" - - def test_content_url(self): - assert ( - self.item.content_url - == "https://connect.example/content/f2f37341-e21d-3d80-c698-a935ad614066/" - ) - - def test_dashboard_url(self): - assert ( - self.item.dashboard_url - == "https://connect.example/connect/#/apps/f2f37341-e21d-3d80-c698-a935ad614066" - ) - - def test_app_role(self): - assert self.item.app_role == "viewer" - - def test_owner(self): - assert "owner" not in self.item - - def test_permissions(self): - assert isinstance(self.item.permissions, Permissions) - - def test_oauth(self): - assert isinstance(self.item.oauth, ContentItemOAuth) - - def test_oauth_associations(self): - assert isinstance(self.item.oauth.associations, ContentItemAssociations) - - def test_tags(self): - assert self.item.tags is None - - class TestContentItemGetContentOwner: @responses.activate def test_owner(self): @@ -211,11 +24,11 @@ def test_owner(self): c = Client("https://connect.example", "12345") item = c.content.get("f2f37341-e21d-3d80-c698-a935ad614066") owner = item.owner - assert owner.guid == "20a79ce3-6e87-4522-9faf-be24228800a4" + assert owner["guid"] == "20a79ce3-6e87-4522-9faf-be24228800a4" # load a second time, assert tha owner is loaded from cached result owner = item.owner - assert owner.guid == "20a79ce3-6e87-4522-9faf-be24228800a4" + assert owner["guid"] == "20a79ce3-6e87-4522-9faf-be24228800a4" assert mock_user_get.call_count == 1 From 279fcd60d1ac3b87019b3625796afa226b2ccb8f Mon Sep 17 00:00:00 2001 From: tdstein Date: Tue, 22 Oct 2024 21:26:13 -0400 Subject: [PATCH 03/31] refactor: introduce the active pattern --- src/posit/connect/content.py | 5 ++ src/posit/connect/context.py | 2 +- src/posit/connect/jobs.py | 89 +++++++++------------------------- src/posit/connect/resources.py | 80 ++++++++++++++++++++++++------ 4 files changed, 92 insertions(+), 84 deletions(-) diff --git a/src/posit/connect/content.py b/src/posit/connect/content.py index 7b1b2087..27142d25 100644 --- a/src/posit/connect/content.py +++ b/src/posit/connect/content.py @@ -9,6 +9,7 @@ from . import tasks from .bundles import Bundles +from .context import Context from .env import EnvVars from .jobs import JobsMixin from .oauth.associations import ContentItemAssociations @@ -34,6 +35,10 @@ class ContentItemOwner(Resource): class ContentItem(JobsMixin, VanityMixin, Resource): + def __init__(self, /, params: ResourceParameters, **kwargs): + ctx = Context(params.session, params.url) + super().__init__(ctx, **kwargs) + def __getitem__(self, key: Any) -> Any: v = super().__getitem__(key) if key == "owner" and isinstance(v, dict): diff --git a/src/posit/connect/context.py b/src/posit/connect/context.py index 8fbbe48c..f8ef13b2 100644 --- a/src/posit/connect/context.py +++ b/src/posit/connect/context.py @@ -40,7 +40,7 @@ def version(self) -> Optional[str]: return value @version.setter - def version(self, value: str): + def version(self, value): self["version"] = value diff --git a/src/posit/connect/jobs.py b/src/posit/connect/jobs.py index d7ebf71e..66b28b6e 100644 --- a/src/posit/connect/jobs.py +++ b/src/posit/connect/jobs.py @@ -1,9 +1,8 @@ -from typing import List, Literal, Optional, Sequence, TypedDict, overload +from typing import Literal, Optional, TypedDict, overload from typing_extensions import NotRequired, Required, Unpack -from .errors import ClientError -from .resources import FinderMethods, Resource, ResourceParameters, Resources +from .resources import Active, ActiveFinderMethods, Resource JobTag = Literal[ "unknown", @@ -32,7 +31,7 @@ ] -class Job(Resource): +class Job(Active): class _Job(TypedDict): # Identifiers id: Required[str] @@ -100,10 +99,12 @@ class _Job(TypedDict): 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, /, params, endpoint, **kwargs: Unpack[_Job]): + def __init__(self, /, params, **kwargs: Unpack[_Job]): super().__init__(params, **kwargs) - key = kwargs["key"] - self._endpoint = endpoint + key + + @property + def _endpoint(self) -> str: + return self._ctx.url + f"v1/content/{self['app_id']}/jobs/{self['key']}" def destroy(self) -> None: """Destroy the job. @@ -118,48 +119,21 @@ def destroy(self) -> None: ---- This action requires administrator, owner, or collaborator privileges. """ - self.params.session.delete(self._endpoint) + self._ctx.session.delete(self._endpoint) -class Jobs(FinderMethods[Job], Sequence[Job], Resources): +class Jobs(ActiveFinderMethods[Job]): """A collection of jobs.""" - def __init__(self, params, endpoint): - super().__init__(Job, params, endpoint) - self._endpoint = endpoint + "jobs" - self._cache = None - - @property - def _data(self) -> List[Job]: - if self._cache: - return self._cache - - response = self.params.session.get(self._endpoint) - results = response.json() - self._cache = [Job(self.params, self._endpoint, **result) for result in results] - return self._cache - - def __getitem__(self, index): - """Retrieve an item or slice from the sequence.""" - return self._data[index] - - def __len__(self): - """Return the length of the sequence.""" - return len(self._data) + _uid = "key" - def __repr__(self): - """Return the string representation of the sequence.""" - return f"Jobs({', '.join(map(str, self._data))})" + def __init__(self, cls, ctx, parent: Active): + super().__init__(cls, ctx) + self._parent = parent - def count(self, value): - """Return the number of occurrences of a value in the sequence.""" - return self._data.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._data) - return self._data.index(value, start, stop) + @property + def _endpoint(self) -> str: + return self._ctx.url + f"v1/content/{self._parent['guid']}/jobs" class _FindByRequest(TypedDict, total=False): # Identifiers @@ -286,28 +260,11 @@ def find_by(self, **conditions: Unpack[_FindByRequest]) -> Optional[Job]: @overload def find_by(self, **conditions): ... - def find_by(self, **conditions): - if "key" in conditions and self._cache is None: - key = conditions["key"] - try: - return self.find(key) - except ClientError as e: - if e.http_status == 404: - return None - raise e - + def find_by(self, **conditions) -> Optional[Job]: return super().find_by(**conditions) - def reload(self) -> "Jobs": - """Unload the cached jobs. - Forces the next access, if any, to query the jobs from the Connect server. - """ - self._cache = None - return self - - -class JobsMixin(Resource): +class JobsMixin(Active, Resource): """Mixin class to add a jobs attribute to a resource.""" class HasGuid(TypedDict): @@ -315,8 +272,6 @@ class HasGuid(TypedDict): guid: Required[str] - def __init__(self, params: ResourceParameters, **kwargs: Unpack[HasGuid]): - super().__init__(params, **kwargs) - uid = kwargs["guid"] - endpoint = self.params.url + f"v1/content/{uid}" - self.jobs = Jobs(self.params, endpoint) + def __init__(self, ctx, **kwargs): + super().__init__(ctx, **kwargs) + self.jobs = Jobs(Job, ctx, self) diff --git a/src/posit/connect/resources.py b/src/posit/connect/resources.py index 46fcd207..18e70594 100644 --- a/src/posit/connect/resources.py +++ b/src/posit/connect/resources.py @@ -1,10 +1,13 @@ +import posixpath import warnings from abc import ABC, abstractmethod from dataclasses import dataclass -from typing import Any, Generic, List, Optional, Type, TypeVar +from typing import Any, Generic, List, Optional, Sequence, Type, TypeVar import requests +from posit.connect.context import Context + from .urls import Url @@ -47,29 +50,74 @@ def __init__(self, params: ResourceParameters) -> None: self.params = params -T = TypeVar("T", bound=Resource) +T = TypeVar("T", bound="Active", covariant=True) + + +class Active(Resource): + def __init__(self, ctx: Context, **kwargs): + params = ResourceParameters(ctx.session, ctx.url) + super().__init__(params, **kwargs) + self._ctx = ctx -class FinderMethods( - Generic[T], - ABC, - Resources, -): - def __init__(self, cls: Type[T], params, endpoint): - super().__init__(params) +class ActiveReader(ABC, Generic[T], Sequence[T]): + def __init__(self, cls: Type[T], ctx: Context): + super().__init__() self._cls = cls - self._endpoint = endpoint + self._ctx = ctx + self._cache = None @property @abstractmethod - def _data(self) -> List[T]: + def _endpoint(self) -> str: raise NotImplementedError() - def find(self, uid): - endpoint = self._endpoint + str(uid) - response = self.params.session.get(endpoint) - result = response.json() - return self._cls(self.params, endpoint=self._endpoint, **result) + @property + def _data(self) -> List[T]: + if self._cache: + return self._cache + + response = self._ctx.session.get(self._endpoint) + results = response.json() + self._cache = [self._cls(self._ctx, **result) for result in results] + return self._cache + + def __getitem__(self, index): + """Retrieve an item or slice from the sequence.""" + return self._data[index] + + def __len__(self): + """Return the length of the sequence.""" + return len(self._data) + + def __str__(self): + return str(self._data) + + def __repr__(self): + return repr(self._data) + + def reload(self): + self._cache = None + return self + + +class ActiveFinderMethods(ActiveReader[T], ABC, Generic[T]): + _uid: str = "guid" + + def find(self, uid) -> T: + if self._cache: + conditions = {self._uid: uid} + result = self.find_by(**conditions) + else: + endpoint = posixpath.join(self._endpoint + uid) + response = self._ctx.session.get(endpoint) + result = response.json() + result = self._cls(self._ctx, **result) + + if not result: + raise ValueError("") + + return result def find_by(self, **conditions: Any) -> Optional[T]: """Finds the first record matching the specified conditions. From e3498700217e0a5558205c338ab3a9e9acb5106d Mon Sep 17 00:00:00 2001 From: tdstein Date: Wed, 23 Oct 2024 12:44:06 -0400 Subject: [PATCH 04/31] add link to parent --- integration/tests/posit/connect/test_jobs.py | 29 ++++++++++++++++++++ src/posit/connect/jobs.py | 14 ++++------ src/posit/connect/resources.py | 18 ++++++++---- 3 files changed, 47 insertions(+), 14 deletions(-) create mode 100644 integration/tests/posit/connect/test_jobs.py diff --git a/integration/tests/posit/connect/test_jobs.py b/integration/tests/posit/connect/test_jobs.py new file mode 100644 index 00000000..6a95da8c --- /dev/null +++ b/integration/tests/posit/connect/test_jobs.py @@ -0,0 +1,29 @@ +from pathlib import Path + +from posit import connect + + +class TestContent: + @classmethod + def setup_class(cls): + cls.client = connect.Client() + cls.content = cls.client.content.create(name="example-quarto-minimal") + + @classmethod + def teardown_class(cls): + cls.content.delete() + assert cls.client.content.count() == 0 + + def test(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 + assert len(jobs) == 1 diff --git a/src/posit/connect/jobs.py b/src/posit/connect/jobs.py index 66b28b6e..c18a279d 100644 --- a/src/posit/connect/jobs.py +++ b/src/posit/connect/jobs.py @@ -99,12 +99,13 @@ class _Job(TypedDict): 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, /, params, **kwargs: Unpack[_Job]): - super().__init__(params, **kwargs) + def __init__(self, ctx, parent: Active, **kwargs: Unpack[_Job]): + super().__init__(ctx, parent, **kwargs) + self._parent = parent @property def _endpoint(self) -> str: - return self._ctx.url + f"v1/content/{self['app_id']}/jobs/{self['key']}" + return self._ctx.url + f"v1/content/{self._parent['guid']}/jobs/{self['key']}" def destroy(self) -> None: """Destroy the job. @@ -128,7 +129,7 @@ class Jobs(ActiveFinderMethods[Job]): _uid = "key" def __init__(self, cls, ctx, parent: Active): - super().__init__(cls, ctx) + super().__init__(cls, ctx, parent) self._parent = parent @property @@ -267,11 +268,6 @@ def find_by(self, **conditions) -> Optional[Job]: class JobsMixin(Active, Resource): """Mixin class to add a jobs attribute to a resource.""" - class HasGuid(TypedDict): - """Has a guid.""" - - guid: Required[str] - def __init__(self, ctx, **kwargs): super().__init__(ctx, **kwargs) self.jobs = Jobs(Job, ctx, self) diff --git a/src/posit/connect/resources.py b/src/posit/connect/resources.py index 18e70594..0630c9dd 100644 --- a/src/posit/connect/resources.py +++ b/src/posit/connect/resources.py @@ -2,7 +2,7 @@ import warnings from abc import ABC, abstractmethod from dataclasses import dataclass -from typing import Any, Generic, List, Optional, Sequence, Type, TypeVar +from typing import Any, Generic, List, Optional, Sequence, Type, TypeVar, overload import requests @@ -54,17 +54,19 @@ def __init__(self, params: ResourceParameters) -> None: class Active(Resource): - def __init__(self, ctx: Context, **kwargs): + def __init__(self, ctx: Context, parent: Optional["Active"] = None, **kwargs): params = ResourceParameters(ctx.session, ctx.url) super().__init__(params, **kwargs) self._ctx = ctx + self._parent = parent class ActiveReader(ABC, Generic[T], Sequence[T]): - def __init__(self, cls: Type[T], ctx: Context): + def __init__(self, cls: Type[T], ctx: Context, parent: Optional[Active] = None): super().__init__() self._cls = cls self._ctx = ctx + self._parent = parent self._cache = None @property @@ -79,9 +81,15 @@ def _data(self) -> List[T]: response = self._ctx.session.get(self._endpoint) results = response.json() - self._cache = [self._cls(self._ctx, **result) for result in results] + self._cache = [self._cls(self._ctx, self._parent, **result) for result in results] return self._cache + @overload + def __getitem__(self, index: int) -> T: ... + + @overload + def __getitem__(self, index: slice) -> Sequence[T]: ... + def __getitem__(self, index): """Retrieve an item or slice from the sequence.""" return self._data[index] @@ -112,7 +120,7 @@ def find(self, uid) -> T: endpoint = posixpath.join(self._endpoint + uid) response = self._ctx.session.get(endpoint) result = response.json() - result = self._cls(self._ctx, **result) + result = self._cls(self._ctx, self._parent, **result) if not result: raise ValueError("") From 533839b18994f5c080cccd6a3ba29d72d7e275c4 Mon Sep 17 00:00:00 2001 From: tdstein Date: Wed, 23 Oct 2024 13:00:05 -0400 Subject: [PATCH 05/31] skip when Quarto unavailable --- integration/tests/posit/connect/test_jobs.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/integration/tests/posit/connect/test_jobs.py b/integration/tests/posit/connect/test_jobs.py index 6a95da8c..cf097073 100644 --- a/integration/tests/posit/connect/test_jobs.py +++ b/integration/tests/posit/connect/test_jobs.py @@ -1,7 +1,12 @@ from pathlib import Path +import pytest +from packaging import version + from posit import connect +from . import CONNECT_VERSION + class TestContent: @classmethod @@ -14,6 +19,10 @@ 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 From 1066ca3b834c7a06a7c90b7ba0f14f5012f184f7 Mon Sep 17 00:00:00 2001 From: tdstein Date: Wed, 23 Oct 2024 16:39:28 -0400 Subject: [PATCH 06/31] adds unit tests --- src/posit/connect/resources.py | 2 +- .../jobs.json | 24 +++ .../jobs/tHawGvHZTosJA2Dx.json | 22 +++ tests/posit/connect/test_jobs.py | 155 ++++++++++++++++++ 4 files changed, 202 insertions(+), 1 deletion(-) create mode 100644 tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/jobs.json create mode 100644 tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/jobs/tHawGvHZTosJA2Dx.json create mode 100644 tests/posit/connect/test_jobs.py diff --git a/src/posit/connect/resources.py b/src/posit/connect/resources.py index 0630c9dd..73e73d6d 100644 --- a/src/posit/connect/resources.py +++ b/src/posit/connect/resources.py @@ -123,7 +123,7 @@ def find(self, uid) -> T: result = self._cls(self._ctx, self._parent, **result) if not result: - raise ValueError("") + raise ValueError(f"Failed to find instance where {self._uid} is '{uid}'") return result diff --git a/tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/jobs.json b/tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/jobs.json new file mode 100644 index 00000000..b497e465 --- /dev/null +++ b/tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/jobs.json @@ -0,0 +1,24 @@ +[ + { + "id": "54", + "ppid": "20253", + "pid": "20253", + "key": "tHawGvHZTosJA2Dx", + "remote_id": "S3ViZXJuZXRlczpyZW5kZXItci1tYXJrZG93bi1zaXRlLWtnODJo", + "app_id": "54", + "variant_id": "54", + "bundle_id": "54", + "start_time": "2006-01-02T15:04:05-07:00", + "end_time": "2006-01-02T15:04:05-07:00", + "last_heartbeat_time": "2006-01-02T15:04:05-07:00", + "queued_time": "2006-01-02T15:04:05-07:00", + "queue_name": "default", + "tag": "build_report", + "exit_code": 0, + "status": 0, + "hostname": "connect", + "cluster": "Kubernetes", + "image": "someorg/image:jammy", + "run_as": "rstudio-connect" + } +] diff --git a/tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/jobs/tHawGvHZTosJA2Dx.json b/tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/jobs/tHawGvHZTosJA2Dx.json new file mode 100644 index 00000000..c1ca8446 --- /dev/null +++ b/tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/jobs/tHawGvHZTosJA2Dx.json @@ -0,0 +1,22 @@ +{ + "id": "54", + "ppid": "20253", + "pid": "20253", + "key": "tHawGvHZTosJA2Dx", + "remote_id": "S3ViZXJuZXRlczpyZW5kZXItci1tYXJrZG93bi1zaXRlLWtnODJo", + "app_id": "54", + "variant_id": "54", + "bundle_id": "54", + "start_time": "2006-01-02T15:04:05-07:00", + "end_time": "2006-01-02T15:04:05-07:00", + "last_heartbeat_time": "2006-01-02T15:04:05-07:00", + "queued_time": "2006-01-02T15:04:05-07:00", + "queue_name": "default", + "tag": "build_report", + "exit_code": 0, + "status": 0, + "hostname": "connect", + "cluster": "Kubernetes", + "image": "someorg/image:jammy", + "run_as": "rstudio-connect" +} diff --git a/tests/posit/connect/test_jobs.py b/tests/posit/connect/test_jobs.py new file mode 100644 index 00000000..b923d8d8 --- /dev/null +++ b/tests/posit/connect/test_jobs.py @@ -0,0 +1,155 @@ +import pytest +import responses + +from posit.connect.client import Client + +from .api import load_mock # type: ignore + + +class TestJobsMixin: + @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/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 + + +class TestJobsFind: + @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/jobs/tHawGvHZTosJA2Dx", + json=load_mock( + "v1/content/f2f37341-e21d-3d80-c698-a935ad614066/jobs/tHawGvHZTosJA2Dx.json" + ), + ) + + c = Client("https://connect.example", "12345") + content = c.content.get("f2f37341-e21d-3d80-c698-a935ad614066") + + job = content.jobs.find("tHawGvHZTosJA2Dx") + assert job["key"] == "tHawGvHZTosJA2Dx" + + @responses.activate + def test_cached(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/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 content.jobs + job = content.jobs.find("tHawGvHZTosJA2Dx") + assert job["key"] == "tHawGvHZTosJA2Dx" + + @responses.activate + def test_miss(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/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 content.jobs + with pytest.raises(ValueError): + content.jobs.find("not-found") + + +class TestJobsFindBy: + @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/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") + + job = content.jobs.find_by(key="tHawGvHZTosJA2Dx") + assert job + 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): + 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/jobs/tHawGvHZTosJA2Dx", + json=load_mock( + "v1/content/f2f37341-e21d-3d80-c698-a935ad614066/jobs/tHawGvHZTosJA2Dx.json" + ), + ) + + responses.delete( + "https://connect.example/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/jobs/tHawGvHZTosJA2Dx", + ) + + c = Client("https://connect.example", "12345") + content = c.content.get("f2f37341-e21d-3d80-c698-a935ad614066") + + job = content.jobs.find("tHawGvHZTosJA2Dx") + job.destroy() From 437c515a783dc65159923446d56f610b34295c4a Mon Sep 17 00:00:00 2001 From: tdstein Date: Wed, 23 Oct 2024 17:20:52 -0400 Subject: [PATCH 07/31] adds docstrings --- integration/tests/posit/connect/test_jobs.py | 2 +- src/posit/connect/resources.py | 206 ++++++++++++++++--- tests/posit/connect/test_jobs.py | 2 + 3 files changed, 186 insertions(+), 24 deletions(-) diff --git a/integration/tests/posit/connect/test_jobs.py b/integration/tests/posit/connect/test_jobs.py index cf097073..9617cf7e 100644 --- a/integration/tests/posit/connect/test_jobs.py +++ b/integration/tests/posit/connect/test_jobs.py @@ -8,7 +8,7 @@ from . import CONNECT_VERSION -class TestContent: +class TestJobs: @classmethod def setup_class(cls): cls.client = connect.Client() diff --git a/src/posit/connect/resources.py b/src/posit/connect/resources.py index 73e73d6d..b435f7ce 100644 --- a/src/posit/connect/resources.py +++ b/src/posit/connect/resources.py @@ -1,4 +1,3 @@ -import posixpath import warnings from abc import ABC, abstractmethod from dataclasses import dataclass @@ -6,8 +5,7 @@ import requests -from posit.connect.context import Context - +from .context import Context from .urls import Url @@ -50,19 +48,106 @@ def __init__(self, params: ResourceParameters) -> None: self.params = params -T = TypeVar("T", bound="Active", covariant=True) +class Active(Resource): + """ + A base class representing an active resource. + Extends the `Resource` class and provides additional functionality for via the session context and an optional parent resource. + + Parameters + ---------- + ctx : Context + The context object containing the session and URL for API interactions. + parent : Optional[Active], optional + An optional parent resource that establishes a hierarchical relationship, by default None. + **kwargs : dict + Additional keyword arguments passed to the parent `Resource` class. + + Attributes + ---------- + _ctx : Context + The session context. + _parent : Optional[Active] + The parent resource, if provided, which establishes a hierarchical relationship. + """ -class Active(Resource): def __init__(self, ctx: Context, parent: Optional["Active"] = None, **kwargs): + """ + Initialize the `Active` resource. + + Parameters + ---------- + ctx : Context + The context object containing session and URL for API interactions. + parent : Optional[Active], optional + An optional parent resource to establish a hierarchical relationship, by default None. + **kwargs : dict + Additional keyword arguments passed to the parent `Resource` class. + """ params = ResourceParameters(ctx.session, ctx.url) super().__init__(params, **kwargs) self._ctx = ctx self._parent = parent -class ActiveReader(ABC, Generic[T], Sequence[T]): - def __init__(self, cls: Type[T], ctx: Context, parent: Optional[Active] = None): +T_co = TypeVar("T_co", bound="Active", covariant=True) +"""A covariant type variable that is bound to the `Active` class, meaning that `T_co` must be or derive from `Active`.""" + + +class ActiveSequence(ABC, Generic[T_co], Sequence[T_co]): + """ + A sequence abstraction for any HTTP GET endpoint that returns a collection. + + It lazily fetches data on demand, caches the results, and allows for standard sequence operations like indexing and slicing. + + Parameters + ---------- + cls : Type[T_co] + The class used to represent each item in the sequence. + ctx : Context + The context object that holds the HTTP session used for sending the GET request. + parent : Optional[Active], optional + An optional parent resource to establish a nested relationship, by default None. + + Attributes + ---------- + _cls : Type[T_co] + The class used to instantiate each item in the sequence. + _ctx : Context + The context containing the HTTP session used to interact with the API. + _parent : Optional[Active] + Optional parent resource for maintaining hierarchical relationships. + _cache : Optional[List[T_co]] + Cached list of items returned from the API. Set to None initially, and populated after the first request. + + Abstract Properties + ------------------- + _endpoint : str + The API endpoint URL for the HTTP GET request. Subclasses are required to implement this property. + + Methods + ------- + _data() -> List[T_co] + Fetch and cache the data from the API. This method sends a GET request to `_endpoint`, parses the + response as JSON, and instantiates each item using `cls`. + + __getitem__(index) -> Union[T_co, Sequence[T_co]] + Retrieve an item or slice from the sequence. Indexing follows the standard Python sequence semantics. + + __len__() -> int + Return the number of items in the sequence. + + __str__() -> str + Return a string representation of the cached data. + + __repr__() -> str + Return a detailed string representation of the cached data. + + reload() -> ActiveSequence + Clear the cache and mark to reload the data from the API on the next operation. + """ + + def __init__(self, cls: Type[T_co], ctx: Context, parent: Optional[Active] = None): super().__init__() self._cls = cls self._ctx = ctx @@ -72,10 +157,33 @@ def __init__(self, cls: Type[T], ctx: Context, parent: Optional[Active] = None): @property @abstractmethod def _endpoint(self) -> str: + """ + Abstract property to define the endpoint URL for the GET request. + + Subclasses must implement this property to return the API endpoint URL that will + be queried to fetch the data. + + Returns + ------- + str + The API endpoint URL. + """ raise NotImplementedError() @property - def _data(self) -> List[T]: + def _data(self) -> List[T_co]: + """ + Fetch and cache the data from the API. + + This method sends a GET request to the `_endpoint` and parses the response as a list of JSON objects. + Each JSON object is used to instantiate an item of type `T_co` using the class specified by `_cls`. + The results are cached after the first request and reused for subsequent access unless reloaded. + + Returns + ------- + List[T_co] + A list of items of type `T_co` representing the fetched data. + """ if self._cache: return self._cache @@ -85,39 +193,85 @@ def _data(self) -> List[T]: return self._cache @overload - def __getitem__(self, index: int) -> T: ... + def __getitem__(self, index: int) -> T_co: ... @overload - def __getitem__(self, index: slice) -> Sequence[T]: ... + def __getitem__(self, index: slice) -> Sequence[T_co]: ... def __getitem__(self, index): - """Retrieve an item or slice from the sequence.""" return self._data[index] - def __len__(self): - """Return the length of the sequence.""" + def __len__(self) -> int: return len(self._data) - def __str__(self): + def __str__(self) -> str: return str(self._data) - def __repr__(self): + def __repr__(self) -> str: return repr(self._data) - def reload(self): + def reload(self) -> "ActiveSequence": + """ + Clear the cache and reload the data from the API on the next access. + + Returns + ------- + ActiveSequence + The current instance with cleared cache, ready to reload data on next access. + """ self._cache = None return self -class ActiveFinderMethods(ActiveReader[T], ABC, Generic[T]): +class ActiveFinderMethods(ActiveSequence[T_co], ABC, Generic[T_co]): + """ + Finder methods. + + Provides various finder methods for locating records in any endpoint supporting HTTP GET requests. + + Attributes + ---------- + _uid : str + The default field name used to uniquely identify records. Defaults to 'guid'. + + Methods + ------- + find(uid) -> T_co + Finds and returns a record by its unique identifier (`uid`). If a cached result exists, it searches within the cache; + otherwise, it makes a GET request to retrieve the data from the endpoint. + + find_by(**conditions: Any) -> Optional[T_co] + Finds the first record that matches the provided conditions. If no record is found, returns None. + """ + _uid: str = "guid" + """The default field name used to uniquely identify records. Defaults to 'guid'.""" + + def find(self, uid) -> T_co: + """ + Find a record by its unique identifier. + + Fetches a record either by searching the cache or by making a GET request to the endpoint. - def find(self, uid) -> T: + Parameters + ---------- + uid : Any + The unique identifier of the record. + + Returns + ------- + T_co + + Raises + ------ + ValueError + If no record is found. + """ if self._cache: conditions = {self._uid: uid} result = self.find_by(**conditions) else: - endpoint = posixpath.join(self._endpoint + uid) + endpoint = self._endpoint + uid response = self._ctx.session.get(endpoint) result = response.json() result = self._cls(self._ctx, self._parent, **result) @@ -127,13 +281,19 @@ def find(self, uid) -> T: return result - def find_by(self, **conditions: Any) -> Optional[T]: - """Finds the first record matching the specified conditions. + def find_by(self, **conditions: Any) -> Optional[T_co]: + """ + Find 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 + ---------- + **conditions : Any Returns ------- - Optional[T] + Optional[T_co] + The first record matching the conditions, or `None` if no match is found. """ return next((v for v in self._data if v.items() >= conditions.items()), None) diff --git a/tests/posit/connect/test_jobs.py b/tests/posit/connect/test_jobs.py index b923d8d8..e74d6081 100644 --- a/tests/posit/connect/test_jobs.py +++ b/tests/posit/connect/test_jobs.py @@ -105,6 +105,7 @@ def test(self): assert job assert job["key"] == "tHawGvHZTosJA2Dx" + class TestJobsReload: @responses.activate def test(self): @@ -129,6 +130,7 @@ def test(self): assert len(content.jobs) == 1 assert mock_get.call_count == 2 + class TestJobDestory: @responses.activate def test(self): From a1ca3779e614a86719c6d109792c772bc5691d1a Mon Sep 17 00:00:00 2001 From: Taylor Steinberg Date: Thu, 24 Oct 2024 15:54:38 -0400 Subject: [PATCH 08/31] Update src/posit/connect/resources.py --- 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 b435f7ce..a2a1e7f3 100644 --- a/src/posit/connect/resources.py +++ b/src/posit/connect/resources.py @@ -91,7 +91,7 @@ def __init__(self, ctx: Context, parent: Optional["Active"] = None, **kwargs): T_co = TypeVar("T_co", bound="Active", covariant=True) -"""A covariant type variable that is bound to the `Active` class, meaning that `T_co` must be or derive from `Active`.""" +"""A covariant type variable that is bound to the `Active` class and must inherit from it.""" class ActiveSequence(ABC, Generic[T_co], Sequence[T_co]): From 82b9b7e1cf89714515518d54aab41428f12cbdb5 Mon Sep 17 00:00:00 2001 From: tdstein Date: Thu, 24 Oct 2024 17:36:38 -0400 Subject: [PATCH 09/31] applies feedback discussed in pull requests --- src/posit/connect/jobs.py | 37 +++++-- src/posit/connect/resources.py | 179 +++++++++++---------------------- 2 files changed, 89 insertions(+), 127 deletions(-) diff --git a/src/posit/connect/jobs.py b/src/posit/connect/jobs.py index c18a279d..acaf0765 100644 --- a/src/posit/connect/jobs.py +++ b/src/posit/connect/jobs.py @@ -2,7 +2,7 @@ from typing_extensions import NotRequired, Required, Unpack -from .resources import Active, ActiveFinderMethods, Resource +from .resources import Active, ActiveFinderMethods, ActiveSequence, Resource JobTag = Literal[ "unknown", @@ -123,22 +123,41 @@ def destroy(self) -> None: self._ctx.session.delete(self._endpoint) -class Jobs(ActiveFinderMethods[Job]): - """A collection of jobs.""" +class Jobs( + ActiveFinderMethods[Job], + ActiveSequence[Job], +): + def __init__(self, ctx, parent: Active, uid="key"): + """A collection of jobs. - _uid = "key" - - def __init__(self, cls, ctx, parent: Active): - super().__init__(cls, ctx, parent) + Parameters + ---------- + ctx : Context + The context containing the HTTP session used to interact with the API. + parent : Active + Parent resource for maintaining hierarchical relationships + uid : str, optional + The default field name used to uniquely identify records, by default "key" + """ + super().__init__(ctx, parent, uid) self._parent = parent @property def _endpoint(self) -> str: return self._ctx.url + f"v1/content/{self._parent['guid']}/jobs" + def _create_instance(self, **kwargs) -> Job: + """Creates a `Job` instance. + + Returns + ------- + Job + """ + return Job(self._ctx, self._parent, **kwargs) + class _FindByRequest(TypedDict, total=False): # Identifiers - id: NotRequired[str] + id: Required[str] """A unique identifier for the job.""" ppid: NotRequired[Optional[str]] @@ -270,4 +289,4 @@ class JobsMixin(Active, Resource): def __init__(self, ctx, **kwargs): super().__init__(ctx, **kwargs) - self.jobs = Jobs(Job, ctx, self) + self.jobs = Jobs(ctx, self) diff --git a/src/posit/connect/resources.py b/src/posit/connect/resources.py index a2a1e7f3..3d652281 100644 --- a/src/posit/connect/resources.py +++ b/src/posit/connect/resources.py @@ -1,9 +1,10 @@ import warnings from abc import ABC, abstractmethod from dataclasses import dataclass -from typing import Any, Generic, List, Optional, Sequence, Type, TypeVar, overload +from typing import Any, Generic, List, Optional, Sequence, TypeVar, overload import requests +from typing_extensions import Self from .context import Context from .urls import Url @@ -48,39 +49,18 @@ def __init__(self, params: ResourceParameters) -> None: self.params = params -class Active(Resource): - """ - A base class representing an active resource. - - Extends the `Resource` class and provides additional functionality for via the session context and an optional parent resource. - - Parameters - ---------- - ctx : Context - The context object containing the session and URL for API interactions. - parent : Optional[Active], optional - An optional parent resource that establishes a hierarchical relationship, by default None. - **kwargs : dict - Additional keyword arguments passed to the parent `Resource` class. - - Attributes - ---------- - _ctx : Context - The session context. - _parent : Optional[Active] - The parent resource, if provided, which establishes a hierarchical relationship. - """ - +class Active(ABC, Resource): def __init__(self, ctx: Context, parent: Optional["Active"] = None, **kwargs): - """ - Initialize the `Active` resource. + """A base class representing an active resource. + + Extends the `Resource` class and provides additional functionality for via the session context and an optional parent resource. Parameters ---------- ctx : Context - The context object containing session and URL for API interactions. + The context object containing the session and URL for API interactions. parent : Optional[Active], optional - An optional parent resource to establish a hierarchical relationship, by default None. + An optional parent resource that establishes a hierarchical relationship, by default None. **kwargs : dict Additional keyword arguments passed to the parent `Resource` class. """ @@ -90,69 +70,27 @@ def __init__(self, ctx: Context, parent: Optional["Active"] = None, **kwargs): self._parent = parent -T_co = TypeVar("T_co", bound="Active", covariant=True) -"""A covariant type variable that is bound to the `Active` class and must inherit from it.""" +T = TypeVar("T", bound="Active") +"""A type variable that is bound to the `Active` class""" -class ActiveSequence(ABC, Generic[T_co], Sequence[T_co]): - """ - A sequence abstraction for any HTTP GET endpoint that returns a collection. - - It lazily fetches data on demand, caches the results, and allows for standard sequence operations like indexing and slicing. +class ActiveSequence(ABC, Generic[T], Sequence[T]): + def __init__(self, ctx: Context, parent: Optional[Active] = None): + """A sequence abstraction for any HTTP GET endpoint that returns a collection. - Parameters - ---------- - cls : Type[T_co] - The class used to represent each item in the sequence. - ctx : Context - The context object that holds the HTTP session used for sending the GET request. - parent : Optional[Active], optional - An optional parent resource to establish a nested relationship, by default None. + It lazily fetches data on demand, caches the results, and allows for standard sequence operations like indexing and slicing. - Attributes - ---------- - _cls : Type[T_co] - The class used to instantiate each item in the sequence. - _ctx : Context - The context containing the HTTP session used to interact with the API. - _parent : Optional[Active] - Optional parent resource for maintaining hierarchical relationships. - _cache : Optional[List[T_co]] - Cached list of items returned from the API. Set to None initially, and populated after the first request. - - Abstract Properties - ------------------- - _endpoint : str - The API endpoint URL for the HTTP GET request. Subclasses are required to implement this property. - - Methods - ------- - _data() -> List[T_co] - Fetch and cache the data from the API. This method sends a GET request to `_endpoint`, parses the - response as JSON, and instantiates each item using `cls`. - - __getitem__(index) -> Union[T_co, Sequence[T_co]] - Retrieve an item or slice from the sequence. Indexing follows the standard Python sequence semantics. - - __len__() -> int - Return the number of items in the sequence. - - __str__() -> str - Return a string representation of the cached data. - - __repr__() -> str - Return a detailed string representation of the cached data. - - reload() -> ActiveSequence - Clear the cache and mark to reload the data from the API on the next operation. - """ - - def __init__(self, cls: Type[T_co], ctx: Context, parent: Optional[Active] = None): + Parameters + ---------- + ctx : Context + The context object that holds the HTTP session used for sending the GET request. + parent : Optional[Active], optional + An optional parent resource to establish a nested relationship, by default None. + """ super().__init__() - self._cls = cls self._ctx = ctx self._parent = parent - self._cache = None + self._cache: Optional[List[T]] = None @property @abstractmethod @@ -171,32 +109,32 @@ def _endpoint(self) -> str: raise NotImplementedError() @property - def _data(self) -> List[T_co]: + def _data(self) -> List[T]: """ Fetch and cache the data from the API. This method sends a GET request to the `_endpoint` and parses the response as a list of JSON objects. - Each JSON object is used to instantiate an item of type `T_co` using the class specified by `_cls`. + Each JSON object is used to instantiate an item of type `T` using the class specified by `_cls`. The results are cached after the first request and reused for subsequent access unless reloaded. Returns ------- - List[T_co] - A list of items of type `T_co` representing the fetched data. + List[T] + A list of items of type `T` representing the fetched data. """ if self._cache: return self._cache response = self._ctx.session.get(self._endpoint) results = response.json() - self._cache = [self._cls(self._ctx, self._parent, **result) for result in results] + self._cache = [self._create_instance(**result) for result in results] return self._cache @overload - def __getitem__(self, index: int) -> T_co: ... + def __getitem__(self, index: int) -> T: ... @overload - def __getitem__(self, index: slice) -> Sequence[T_co]: ... + def __getitem__(self, index: slice) -> Sequence[T]: ... def __getitem__(self, index): return self._data[index] @@ -210,7 +148,17 @@ def __str__(self) -> str: def __repr__(self) -> str: return repr(self._data) - def reload(self) -> "ActiveSequence": + @abstractmethod + def _create_instance(self, **kwargs) -> T: + """Create an instance of 'T'. + + Returns + ------- + T + """ + raise NotImplementedError() + + def reload(self) -> Self: """ Clear the cache and reload the data from the API on the next access. @@ -223,31 +171,25 @@ def reload(self) -> "ActiveSequence": return self -class ActiveFinderMethods(ActiveSequence[T_co], ABC, Generic[T_co]): - """ - Finder methods. +class ActiveFinderMethods(ActiveSequence[T], ABC, Generic[T]): + def __init__(self, ctx: Context, parent: Optional[Active] = None, uid: str = "guid"): + """Finder methods. - Provides various finder methods for locating records in any endpoint supporting HTTP GET requests. + Provides various finder methods for locating records in any endpoint supporting HTTP GET requests. - Attributes - ---------- - _uid : str - The default field name used to uniquely identify records. Defaults to 'guid'. - - Methods - ------- - find(uid) -> T_co - Finds and returns a record by its unique identifier (`uid`). If a cached result exists, it searches within the cache; - otherwise, it makes a GET request to retrieve the data from the endpoint. - - find_by(**conditions: Any) -> Optional[T_co] - Finds the first record that matches the provided conditions. If no record is found, returns None. - """ - - _uid: str = "guid" - """The default field name used to uniquely identify records. Defaults to 'guid'.""" + Parameters + ---------- + ctx : Context + The context containing the HTTP session used to interact with the API. + parent : Optional[Active], optional + Optional parent resource for maintaining hierarchical relationships, by default None + uid : str, optional + The default field name used to uniquely identify records, by default "guid" + """ + super().__init__(ctx, parent) + self._uid = uid - def find(self, uid) -> T_co: + def find(self, uid) -> T: """ Find a record by its unique identifier. @@ -260,13 +202,14 @@ def find(self, uid) -> T_co: Returns ------- - T_co + T Raises ------ ValueError If no record is found. """ + # todo - add some more comments about this if self._cache: conditions = {self._uid: uid} result = self.find_by(**conditions) @@ -274,14 +217,14 @@ def find(self, uid) -> T_co: endpoint = self._endpoint + uid response = self._ctx.session.get(endpoint) result = response.json() - result = self._cls(self._ctx, self._parent, **result) + result = self._create_instance(**result) if not result: raise ValueError(f"Failed to find instance where {self._uid} is '{uid}'") return result - def find_by(self, **conditions: Any) -> Optional[T_co]: + def find_by(self, **conditions: Any) -> Optional[T]: """ Find the first record matching the specified conditions. @@ -293,7 +236,7 @@ def find_by(self, **conditions: Any) -> Optional[T_co]: Returns ------- - Optional[T_co] + Optional[T] The first record matching the conditions, or `None` if no match is found. """ return next((v for v in self._data if v.items() >= conditions.items()), None) From 6b8126dfc48184f95f3ed7af2787e48a1ef80bef Mon Sep 17 00:00:00 2001 From: tdstein Date: Fri, 25 Oct 2024 12:31:57 -0400 Subject: [PATCH 10/31] refactor: inject url path parts instead of endpoints --- src/posit/connect/content.py | 3 +- src/posit/connect/jobs.py | 82 +++++++++++++++-------- src/posit/connect/resources.py | 108 ++++++++++++++----------------- src/posit/connect/vanities.py | 18 ++---- tests/posit/connect/test_jobs.py | 7 +- 5 files changed, 116 insertions(+), 102 deletions(-) diff --git a/src/posit/connect/content.py b/src/posit/connect/content.py index 27142d25..3512184a 100644 --- a/src/posit/connect/content.py +++ b/src/posit/connect/content.py @@ -37,7 +37,8 @@ class ContentItemOwner(Resource): class ContentItem(JobsMixin, VanityMixin, Resource): def __init__(self, /, params: ResourceParameters, **kwargs): ctx = Context(params.session, params.url) - super().__init__(ctx, **kwargs) + base = f"v1/content/{kwargs['guid']}" + super().__init__(ctx, base, **kwargs) def __getitem__(self, key: Any) -> Any: v = super().__getitem__(key) diff --git a/src/posit/connect/jobs.py b/src/posit/connect/jobs.py index acaf0765..0f5e0dbb 100644 --- a/src/posit/connect/jobs.py +++ b/src/posit/connect/jobs.py @@ -1,7 +1,9 @@ -from typing import Literal, Optional, TypedDict, overload +from typing import Any, Literal, Optional, TypedDict, overload from typing_extensions import NotRequired, Required, Unpack +from posit.connect.context import Context + from .resources import Active, ActiveFinderMethods, ActiveSequence, Resource JobTag = Literal[ @@ -99,13 +101,31 @@ class _Job(TypedDict): 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, parent: Active, **kwargs: Unpack[_Job]): - super().__init__(ctx, parent, **kwargs) - self._parent = parent + @overload + def __init__(self, ctx: Context, base: str, uid: str, /, **kwargs: Unpack[_Job]): + """A Job. + + A Job represents single execution instance of Content on Connect. Whenever Content runs, whether it's a scheduled report, a script execution, or server processes related to an application, a Job is created to manage and encapsulate that execution. + + Parameters + ---------- + ctx : Context + The context object that holds the HTTP session used for sending the GET request. + parent : ContentItem + The Content related to this Job. + + Notes + ----- + A Job is a reference to a server process on Connect, it is not the process itself. Jobs are executed asynchronously + """ + ... + + @overload + def __init__(self, ctx: Context, base: str, uid: str, /, **kwargs: Any): ... - @property - def _endpoint(self) -> str: - return self._ctx.url + f"v1/content/{self._parent['guid']}/jobs/{self['key']}" + def __init__(self, ctx: Context, base: str, uid: str, /, **kwargs: Any): + super().__init__(ctx, **kwargs) + self._endpoint = ctx.url + base + uid def destroy(self) -> None: """Destroy the job. @@ -123,37 +143,38 @@ def destroy(self) -> None: self._ctx.session.delete(self._endpoint) -class Jobs( - ActiveFinderMethods[Job], - ActiveSequence[Job], -): - def __init__(self, ctx, parent: Active, uid="key"): +class Jobs(ActiveFinderMethods[Job], ActiveSequence[Job]): + def __init__(self, ctx: Context, base: str, path: str = "jobs", uid="key"): """A collection of jobs. Parameters ---------- ctx : Context - The context containing the HTTP session used to interact with the API. - parent : Active - Parent resource for maintaining hierarchical relationships + The context object containing the session and URL for API interactions + base : str + The base HTTP path for the collection endpoint + name : str + The collection name, by default "jobs" uid : str, optional - The default field name used to uniquely identify records, by default "key" + The field name used to uniquely identify records, by default "key" """ - super().__init__(ctx, parent, uid) - self._parent = parent + super().__init__(ctx, base, path, uid) - @property - def _endpoint(self) -> str: - return self._ctx.url + f"v1/content/{self._parent['guid']}/jobs" + def _create_instance(self, base: str, uid: str, **kwargs: Any) -> Job: + """Creates a Job instance. - def _create_instance(self, **kwargs) -> Job: - """Creates a `Job` instance. + Parameters + ---------- + base : str + The base HTTP path for the instance endpoint + uid : str + The unique identifier for the instance. Returns ------- Job """ - return Job(self._ctx, self._parent, **kwargs) + return Job(self._ctx, base, uid, **kwargs) class _FindByRequest(TypedDict, total=False): # Identifiers @@ -287,6 +308,15 @@ def find_by(self, **conditions) -> Optional[Job]: class JobsMixin(Active, Resource): """Mixin class to add a jobs attribute to a resource.""" - def __init__(self, ctx, **kwargs): + def __init__(self, ctx: Context, base: str, /, **kwargs): + """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 + base : str + The base path associated with the instance. + """ super().__init__(ctx, **kwargs) - self.jobs = Jobs(ctx, self) + self.jobs = Jobs(ctx, base) diff --git a/src/posit/connect/resources.py b/src/posit/connect/resources.py index 3d652281..e35bdf94 100644 --- a/src/posit/connect/resources.py +++ b/src/posit/connect/resources.py @@ -1,3 +1,4 @@ +import posixpath import warnings from abc import ABC, abstractmethod from dataclasses import dataclass @@ -50,7 +51,7 @@ def __init__(self, params: ResourceParameters) -> None: class Active(ABC, Resource): - def __init__(self, ctx: Context, parent: Optional["Active"] = None, **kwargs): + def __init__(self, ctx: Context, **kwargs): """A base class representing an active resource. Extends the `Resource` class and provides additional functionality for via the session context and an optional parent resource. @@ -59,15 +60,12 @@ def __init__(self, ctx: Context, parent: Optional["Active"] = None, **kwargs): ---------- ctx : Context The context object containing the session and URL for API interactions. - parent : Optional[Active], optional - An optional parent resource that establishes a hierarchical relationship, by default None. **kwargs : dict Additional keyword arguments passed to the parent `Resource` class. """ params = ResourceParameters(ctx.session, ctx.url) super().__init__(params, **kwargs) self._ctx = ctx - self._parent = parent T = TypeVar("T", bound="Active") @@ -75,7 +73,7 @@ def __init__(self, ctx: Context, parent: Optional["Active"] = None, **kwargs): class ActiveSequence(ABC, Generic[T], Sequence[T]): - def __init__(self, ctx: Context, parent: Optional[Active] = None): + def __init__(self, ctx: Context, base: str, name: str, uid="guid"): """A sequence abstraction for any HTTP GET endpoint that returns a collection. It lazily fetches data on demand, caches the results, and allows for standard sequence operations like indexing and slicing. @@ -83,31 +81,33 @@ def __init__(self, ctx: Context, parent: Optional[Active] = None): Parameters ---------- ctx : Context - The context object that holds the HTTP session used for sending the GET request. - parent : Optional[Active], optional - An optional parent resource to establish a nested relationship, by default None. + The context object containing the session and URL for API interactions + base : str + The base HTTP path for the collection endpoint + name : str + The collection name + uid : str, optional + The field name used to uniquely identify records, by default "guid" + + Attributes + ---------- + _ctx : Context + The context object containing the session and URL for API interactions + _path : str + The HTTP path for the collection endpoint. + _endpoint : Url + The HTTP URL for the collection endpoint. + _uid : str + The default field name used to uniquely identify records. + _cache: Optional[List[T]] """ super().__init__() self._ctx = ctx - self._parent = parent + self._path: str = posixpath.join(base, name) + self._endpoint: Url = ctx.url + self._path + self._uid: str = uid self._cache: Optional[List[T]] = None - @property - @abstractmethod - def _endpoint(self) -> str: - """ - Abstract property to define the endpoint URL for the GET request. - - Subclasses must implement this property to return the API endpoint URL that will - be queried to fetch the data. - - Returns - ------- - str - The API endpoint URL. - """ - raise NotImplementedError() - @property def _data(self) -> List[T]: """ @@ -127,7 +127,13 @@ def _data(self) -> List[T]: response = self._ctx.session.get(self._endpoint) results = response.json() - self._cache = [self._create_instance(**result) for result in results] + + self._cache = [] + for result in results: + uid = result[self._uid] + instance = self._create_instance(self._path, uid, **result) + self._cache.append(instance) + return self._cache @overload @@ -149,7 +155,7 @@ def __repr__(self) -> str: return repr(self._data) @abstractmethod - def _create_instance(self, **kwargs) -> T: + def _create_instance(self, base: str, uid: str, /, **kwargs: Any) -> T: """Create an instance of 'T'. Returns @@ -171,29 +177,12 @@ def reload(self) -> Self: return self -class ActiveFinderMethods(ActiveSequence[T], ABC, Generic[T]): - def __init__(self, ctx: Context, parent: Optional[Active] = None, uid: str = "guid"): - """Finder methods. - - Provides various finder methods for locating records in any endpoint supporting HTTP GET requests. - - Parameters - ---------- - ctx : Context - The context containing the HTTP session used to interact with the API. - parent : Optional[Active], optional - Optional parent resource for maintaining hierarchical relationships, by default None - uid : str, optional - The default field name used to uniquely identify records, by default "guid" - """ - super().__init__(ctx, parent) - self._uid = uid - +class ActiveFinderMethods(ActiveSequence[T], ABC): def find(self, uid) -> T: """ Find a record by its unique identifier. - Fetches a record either by searching the cache or by making a GET request to the endpoint. + If the cache is already populated, it is checked first for matching record. If not, a conventional GET request is made to the Connect server. Parameters ---------- @@ -203,24 +192,23 @@ def find(self, uid) -> T: Returns ------- T - - Raises - ------ - ValueError - If no record is found. """ - # todo - add some more comments about this if self._cache: + # Check if the record already exists in the cache. + # It is assumed that local cache scan is faster than an additional HTTP request. conditions = {self._uid: uid} result = self.find_by(**conditions) - else: - endpoint = self._endpoint + uid - response = self._ctx.session.get(endpoint) - result = response.json() - result = self._create_instance(**result) - - if not result: - raise ValueError(f"Failed to find instance where {self._uid} is '{uid}'") + if result: + return result + + endpoint = self._endpoint + uid + response = self._ctx.session.get(endpoint) + result = response.json() + result = self._create_instance(self._path, uid, **result) + + # Invalidate the cache. + # It is assumed that the cache is stale since a record exists on the server and not in the cache. + self._cache = None return result diff --git a/src/posit/connect/vanities.py b/src/posit/connect/vanities.py index 571dccef..833c8e29 100644 --- a/src/posit/connect/vanities.py +++ b/src/posit/connect/vanities.py @@ -70,10 +70,7 @@ def __init__( super().__init__(params, **kwargs) self._after_destroy = after_destroy self._content_guid = kwargs["content_guid"] - - @property - def _endpoint(self): - return self.params.url + f"v1/content/{self._content_guid}/vanity" + self.__endpoint = self.params.url + f"v1/content/{self._content_guid}/vanity" def destroy(self) -> None: """Destroy the vanity. @@ -91,7 +88,7 @@ def destroy(self) -> None: ---- This action requires administrator privileges. """ - self.params.session.delete(self._endpoint) + self.params.session.delete(self.__endpoint) if self._after_destroy: self._after_destroy() @@ -128,12 +125,9 @@ class HasGuid(TypedDict): def __init__(self, params: ResourceParameters, **kwargs: Unpack[HasGuid]): super().__init__(params, **kwargs) self._content_guid = kwargs["guid"] + self.__endpoint = self.params.url + f"v1/content/{self._content_guid}/vanity" self._vanity: Optional[Vanity] = None - @property - def _endpoint(self): - return self.params.url + f"v1/content/{self._content_guid}/vanity" - @property def vanity(self) -> Optional[str]: """Get the vanity.""" @@ -220,7 +214,8 @@ def create_vanity(self, **kwargs: Unpack[CreateVanityRequest]) -> Vanity: -------- If setting force=True, the destroy operation performed on the other vanity is irreversible. """ - response = self.params.session.put(self._endpoint, json=kwargs) + print(self.__endpoint) + response = self.params.session.put(self.__endpoint, json=kwargs) result = response.json() return Vanity(self.params, **result) @@ -231,6 +226,7 @@ def find_vanity(self) -> Vanity: ------- Vanity """ - response = self.params.session.get(self._endpoint) + print(self.__endpoint) + response = self.params.session.get(self.__endpoint) result = response.json() return Vanity(self.params, **result) diff --git a/tests/posit/connect/test_jobs.py b/tests/posit/connect/test_jobs.py index e74d6081..a57216e0 100644 --- a/tests/posit/connect/test_jobs.py +++ b/tests/posit/connect/test_jobs.py @@ -73,15 +73,14 @@ def test_miss(self): ) 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"), + "https://connect.example/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/jobs/not-found", + status=404, ) c = Client("https://connect.example", "12345") content = c.content.get("f2f37341-e21d-3d80-c698-a935ad614066") - assert content.jobs - with pytest.raises(ValueError): + with pytest.raises(Exception): content.jobs.find("not-found") From b64f3e77f33764773853aacae79ba36f1df4d2d9 Mon Sep 17 00:00:00 2001 From: tdstein Date: Mon, 28 Oct 2024 10:39:15 -0400 Subject: [PATCH 11/31] update docstrings --- src/posit/connect/jobs.py | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/src/posit/connect/jobs.py b/src/posit/connect/jobs.py index 0f5e0dbb..9d150584 100644 --- a/src/posit/connect/jobs.py +++ b/src/posit/connect/jobs.py @@ -2,8 +2,7 @@ from typing_extensions import NotRequired, Required, Unpack -from posit.connect.context import Context - +from .context import Context from .resources import Active, ActiveFinderMethods, ActiveSequence, Resource JobTag = Literal[ @@ -102,7 +101,13 @@ class _Job(TypedDict): """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 __init__(self, ctx: Context, base: str, uid: str, /, **kwargs: Unpack[_Job]): + def __init__(self, ctx: Context, base: str, uid: str, /, **attributes: Unpack[_Job]): + ... + + @overload + def __init__(self, ctx: Context, base: str, uid: str, /, **attributes: Any): ... + + def __init__(self, ctx: Context, base: str, uid: str, /, **attributes: Any): """A Job. A Job represents single execution instance of Content on Connect. Whenever Content runs, whether it's a scheduled report, a script execution, or server processes related to an application, a Job is created to manage and encapsulate that execution. @@ -110,21 +115,19 @@ def __init__(self, ctx: Context, base: str, uid: str, /, **kwargs: Unpack[_Job]) Parameters ---------- ctx : Context - The context object that holds the HTTP session used for sending the GET request. - parent : ContentItem - The Content related to this Job. + The context object containing the session and URL for API interactions. + base : str + The base HTTP path for the Job endpoint (e.g., '/jobs') + uid : str + The unique identifier + **attributes + Object items passed to the base resource dictionary. Notes ----- - A Job is a reference to a server process on Connect, it is not the process itself. Jobs are executed asynchronously + A Job is a reference to a server process on Connect, it is not the process itself. Jobs are executed asynchronously. """ - ... - - @overload - def __init__(self, ctx: Context, base: str, uid: str, /, **kwargs: Any): ... - - def __init__(self, ctx: Context, base: str, uid: str, /, **kwargs: Any): - super().__init__(ctx, **kwargs) + super().__init__(ctx, **attributes) self._endpoint = ctx.url + base + uid def destroy(self) -> None: From f57340dbe567bf48ae54a6a3e932854fdcba05e3 Mon Sep 17 00:00:00 2001 From: tdstein Date: Mon, 28 Oct 2024 12:14:24 -0400 Subject: [PATCH 12/31] renames init arguments to path and pathinfo --- src/posit/connect/content.py | 5 ++- src/posit/connect/jobs.py | 74 +++++++++++++--------------------- src/posit/connect/resources.py | 51 ++++++++++++----------- 3 files changed, 57 insertions(+), 73 deletions(-) diff --git a/src/posit/connect/content.py b/src/posit/connect/content.py index 3512184a..055e60bc 100644 --- a/src/posit/connect/content.py +++ b/src/posit/connect/content.py @@ -37,8 +37,9 @@ class ContentItemOwner(Resource): class ContentItem(JobsMixin, VanityMixin, Resource): def __init__(self, /, params: ResourceParameters, **kwargs): ctx = Context(params.session, params.url) - base = f"v1/content/{kwargs['guid']}" - super().__init__(ctx, base, **kwargs) + path = f"v1/content" + pathinfo = kwargs["guid"] + super().__init__(ctx, path, pathinfo, **kwargs) def __getitem__(self, key: Any) -> Any: v = super().__getitem__(key) diff --git a/src/posit/connect/jobs.py b/src/posit/connect/jobs.py index 9d150584..84168a22 100644 --- a/src/posit/connect/jobs.py +++ b/src/posit/connect/jobs.py @@ -101,34 +101,13 @@ class _Job(TypedDict): """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 __init__(self, ctx: Context, base: str, uid: str, /, **attributes: Unpack[_Job]): - ... + def __init__(self, ctx: Context, path: str, pathinfo: str, /, **attributes: Unpack[_Job]): ... @overload - def __init__(self, ctx: Context, base: str, uid: str, /, **attributes: Any): ... - - def __init__(self, ctx: Context, base: str, uid: str, /, **attributes: Any): - """A Job. - - A Job represents single execution instance of Content on Connect. Whenever Content runs, whether it's a scheduled report, a script execution, or server processes related to an application, a Job is created to manage and encapsulate that execution. + def __init__(self, ctx: Context, path: str, pathinfo: str, /, **attributes: Any): ... - Parameters - ---------- - ctx : Context - The context object containing the session and URL for API interactions. - base : str - The base HTTP path for the Job endpoint (e.g., '/jobs') - uid : str - The unique identifier - **attributes - Object items passed to the base resource dictionary. - - Notes - ----- - A Job is a reference to a server process on Connect, it is not the process itself. Jobs are executed asynchronously. - """ - super().__init__(ctx, **attributes) - self._endpoint = ctx.url + base + uid + def __init__(self, ctx: Context, path: str, pathinfo: str, /, **attributes: Any): + super().__init__(ctx, path, pathinfo, **attributes) def destroy(self) -> None: """Destroy the job. @@ -143,41 +122,42 @@ def destroy(self) -> None: ---- This action requires administrator, owner, or collaborator privileges. """ - self._ctx.session.delete(self._endpoint) + endpoint = self._ctx.url + self._path + self._ctx.session.delete(endpoint) class Jobs(ActiveFinderMethods[Job], ActiveSequence[Job]): - def __init__(self, ctx: Context, base: str, path: str = "jobs", uid="key"): + def __init__(self, ctx: Context, path: str, pathinfo: str = "jobs", uid: str = "key"): """A collection of jobs. Parameters ---------- ctx : Context The context object containing the session and URL for API interactions - base : str - The base HTTP path for the collection endpoint - name : str - The collection name, by default "jobs" + path : str + The HTTP path component for the collection endpoint + pathinfo : str + The HTTP part of the path directed at a specific resource, by default "jobs" uid : str, optional - The field name used to uniquely identify records, by default "key" + The field name used to uniquely identify records, by default "guid" """ - super().__init__(ctx, base, path, uid) + super().__init__(ctx, path, pathinfo, uid) - def _create_instance(self, base: str, uid: str, **kwargs: Any) -> Job: + def _create_instance(self, path: str, pathinfo: str, **kwargs: Any) -> Job: """Creates a Job instance. Parameters ---------- - base : str - The base HTTP path for the instance endpoint - uid : str - The unique identifier for the instance. + path : str + The HTTP path component for the collection endpoint + pathinfo : str + The HTTP part of the path directed at a specific resource Returns ------- Job """ - return Job(self._ctx, base, uid, **kwargs) + return Job(self._ctx, path, pathinfo, **kwargs) class _FindByRequest(TypedDict, total=False): # Identifiers @@ -311,15 +291,19 @@ def find_by(self, **conditions) -> Optional[Job]: class JobsMixin(Active, Resource): """Mixin class to add a jobs attribute to a resource.""" - def __init__(self, ctx: Context, base: str, /, **kwargs): + def __init__(self, ctx, path, pathinfo="", /, **kwargs): """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 - base : str - The base path associated with the instance. + The context object containing the session and URL for API interactions. + path : str + The HTTP path component for the collection endpoint + pathinfo : str + The HTTP part of the path directed at a specific resource + **attributes : dict + Resource attributes passed """ - super().__init__(ctx, **kwargs) - self.jobs = Jobs(ctx, base) + super().__init__(ctx, path, pathinfo, **kwargs) + self.jobs = Jobs(ctx, self._path) diff --git a/src/posit/connect/resources.py b/src/posit/connect/resources.py index e35bdf94..2fda5304 100644 --- a/src/posit/connect/resources.py +++ b/src/posit/connect/resources.py @@ -51,8 +51,8 @@ def __init__(self, params: ResourceParameters) -> None: class Active(ABC, Resource): - def __init__(self, ctx: Context, **kwargs): - """A base class representing an active resource. + def __init__(self, ctx: Context, path: str, pathinfo: str = "", /, **attributes): + """A dict abstraction for any HTTP endpoint that returns a singular resource. Extends the `Resource` class and provides additional functionality for via the session context and an optional parent resource. @@ -60,12 +60,24 @@ def __init__(self, ctx: Context, **kwargs): ---------- ctx : Context The context object containing the session and URL for API interactions. - **kwargs : dict - Additional keyword arguments passed to the parent `Resource` class. + path : str + The HTTP path component for the collection endpoint + pathinfo : str + The HTTP part of the path directed at a specific resource + **attributes : dict + Resource attributes passed + + Attributes + ---------- + _ctx : Context + The context object containing the session and URL for API interactions + _path : str + The HTTP path for the collection endpoint. """ params = ResourceParameters(ctx.session, ctx.url) - super().__init__(params, **kwargs) + super().__init__(params, **attributes) self._ctx = ctx + self._path = posixpath.join(path, pathinfo) T = TypeVar("T", bound="Active") @@ -73,39 +85,25 @@ def __init__(self, ctx: Context, **kwargs): class ActiveSequence(ABC, Generic[T], Sequence[T]): - def __init__(self, ctx: Context, base: str, name: str, uid="guid"): + def __init__(self, ctx: Context, path: str, pathinfo: str = "", uid: str = "guid"): """A sequence abstraction for any HTTP GET endpoint that returns a collection. It lazily fetches data on demand, caches the results, and allows for standard sequence operations like indexing and slicing. - Parameters - ---------- - ctx : Context - The context object containing the session and URL for API interactions - base : str - The base HTTP path for the collection endpoint - name : str - The collection name - uid : str, optional - The field name used to uniquely identify records, by default "guid" - Attributes ---------- _ctx : Context The context object containing the session and URL for API interactions _path : str The HTTP path for the collection endpoint. - _endpoint : Url - The HTTP URL for the collection endpoint. _uid : str - The default field name used to uniquely identify records. + The field name used to uniquely identify records. _cache: Optional[List[T]] """ super().__init__() self._ctx = ctx - self._path: str = posixpath.join(base, name) - self._endpoint: Url = ctx.url + self._path - self._uid: str = uid + self._path = posixpath.join(path, pathinfo) + self._uid = uid self._cache: Optional[List[T]] = None @property @@ -125,7 +123,8 @@ def _data(self) -> List[T]: if self._cache: return self._cache - response = self._ctx.session.get(self._endpoint) + endpoint = self._ctx.url + self._path + response = self._ctx.session.get(endpoint) results = response.json() self._cache = [] @@ -155,7 +154,7 @@ def __repr__(self) -> str: return repr(self._data) @abstractmethod - def _create_instance(self, base: str, uid: str, /, **kwargs: Any) -> T: + def _create_instance(self, path: str, pathinfo: str, /, **kwargs: Any) -> T: """Create an instance of 'T'. Returns @@ -201,7 +200,7 @@ def find(self, uid) -> T: if result: return result - endpoint = self._endpoint + uid + endpoint = self._ctx.url + self._path + uid response = self._ctx.session.get(endpoint) result = response.json() result = self._create_instance(self._path, uid, **result) From 72b62ac524c125c9e73bee4eb706afa46233dbe7 Mon Sep 17 00:00:00 2001 From: tdstein Date: Mon, 28 Oct 2024 12:38:03 -0400 Subject: [PATCH 13/31] minor cleanup --- src/posit/connect/vanities.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/posit/connect/vanities.py b/src/posit/connect/vanities.py index 833c8e29..dab794e1 100644 --- a/src/posit/connect/vanities.py +++ b/src/posit/connect/vanities.py @@ -70,7 +70,6 @@ def __init__( super().__init__(params, **kwargs) self._after_destroy = after_destroy self._content_guid = kwargs["content_guid"] - self.__endpoint = self.params.url + f"v1/content/{self._content_guid}/vanity" def destroy(self) -> None: """Destroy the vanity. @@ -88,7 +87,8 @@ def destroy(self) -> None: ---- This action requires administrator privileges. """ - self.params.session.delete(self.__endpoint) + endpoint = self.params.url + f"v1/content/{self._content_guid}/vanity" + self.params.session.delete(endpoint) if self._after_destroy: self._after_destroy() @@ -125,7 +125,6 @@ class HasGuid(TypedDict): def __init__(self, params: ResourceParameters, **kwargs: Unpack[HasGuid]): super().__init__(params, **kwargs) self._content_guid = kwargs["guid"] - self.__endpoint = self.params.url + f"v1/content/{self._content_guid}/vanity" self._vanity: Optional[Vanity] = None @property @@ -214,8 +213,8 @@ def create_vanity(self, **kwargs: Unpack[CreateVanityRequest]) -> Vanity: -------- If setting force=True, the destroy operation performed on the other vanity is irreversible. """ - print(self.__endpoint) - response = self.params.session.put(self.__endpoint, json=kwargs) + endpoint = self.params.url + f"v1/content/{self._content_guid}/vanity" + response = self.params.session.put(endpoint, json=kwargs) result = response.json() return Vanity(self.params, **result) @@ -226,7 +225,7 @@ def find_vanity(self) -> Vanity: ------- Vanity """ - print(self.__endpoint) - response = self.params.session.get(self.__endpoint) + endpoint = self.params.url + f"v1/content/{self._content_guid}/vanity" + response = self.params.session.get(endpoint) result = response.json() return Vanity(self.params, **result) From f1d6f4279b9bb320d1e3dec76736419e336a57d5 Mon Sep 17 00:00:00 2001 From: tdstein Date: Tue, 29 Oct 2024 17:55:03 -0400 Subject: [PATCH 14/31] refactors _data property into _get_or_fetch method --- src/posit/connect/resources.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/posit/connect/resources.py b/src/posit/connect/resources.py index 2fda5304..2a10f056 100644 --- a/src/posit/connect/resources.py +++ b/src/posit/connect/resources.py @@ -106,8 +106,7 @@ def __init__(self, ctx: Context, path: str, pathinfo: str = "", uid: str = "guid self._uid = uid self._cache: Optional[List[T]] = None - @property - def _data(self) -> List[T]: + def _get_or_fetch(self) -> List[T]: """ Fetch and cache the data from the API. @@ -142,16 +141,20 @@ def __getitem__(self, index: int) -> T: ... def __getitem__(self, index: slice) -> Sequence[T]: ... def __getitem__(self, index): - return self._data[index] + data = self._get_or_fetch() + return data[index] def __len__(self) -> int: - return len(self._data) + data = self._get_or_fetch() + return len(data) def __str__(self) -> str: - return str(self._data) + data = self._get_or_fetch() + return str(data) def __repr__(self) -> str: - return repr(self._data) + data = self._get_or_fetch() + return repr(data) @abstractmethod def _create_instance(self, path: str, pathinfo: str, /, **kwargs: Any) -> T: @@ -226,4 +229,5 @@ def find_by(self, **conditions: Any) -> Optional[T]: Optional[T] The first record matching the conditions, or `None` if no match is found. """ - return next((v for v in self._data if v.items() >= conditions.items()), None) + data = self._get_or_fetch() + return next((v for v in data if v.items() >= conditions.items()), None) From dd74d606d10b5dd6dc4531423d8c8e6279fdf889 Mon Sep 17 00:00:00 2001 From: tdstein Date: Tue, 29 Oct 2024 17:58:30 -0400 Subject: [PATCH 15/31] fix method signature --- src/posit/connect/jobs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/posit/connect/jobs.py b/src/posit/connect/jobs.py index 84168a22..1ab87a70 100644 --- a/src/posit/connect/jobs.py +++ b/src/posit/connect/jobs.py @@ -143,7 +143,7 @@ def __init__(self, ctx: Context, path: str, pathinfo: str = "jobs", uid: str = " """ super().__init__(ctx, path, pathinfo, uid) - def _create_instance(self, path: str, pathinfo: str, **kwargs: Any) -> Job: + def _create_instance(self, path: str, pathinfo: str, /, **kwargs: Any) -> Job: """Creates a Job instance. Parameters From fb52c8367a7286317fad86de7513d83c1761ae46 Mon Sep 17 00:00:00 2001 From: tdstein Date: Tue, 29 Oct 2024 17:58:59 -0400 Subject: [PATCH 16/31] fix cache check --- 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 2a10f056..a207ef6f 100644 --- a/src/posit/connect/resources.py +++ b/src/posit/connect/resources.py @@ -119,7 +119,7 @@ def _get_or_fetch(self) -> List[T]: List[T] A list of items of type `T` representing the fetched data. """ - if self._cache: + if self._cache is not None: return self._cache endpoint = self._ctx.url + self._path From bbbd6b4a64d8e903414fe57c241c74f556bc13ce Mon Sep 17 00:00:00 2001 From: Taylor Steinberg Date: Tue, 29 Oct 2024 19:32:01 -0400 Subject: [PATCH 17/31] Update src/posit/connect/resources.py Co-authored-by: Barret Schloerke --- src/posit/connect/resources.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/posit/connect/resources.py b/src/posit/connect/resources.py index a207ef6f..5babddf0 100644 --- a/src/posit/connect/resources.py +++ b/src/posit/connect/resources.py @@ -180,6 +180,9 @@ def reload(self) -> Self: class ActiveFinderMethods(ActiveSequence[T], ABC): + """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. From bc2cfcbb928f3d1dffb50c71554c615b5ce8f50e Mon Sep 17 00:00:00 2001 From: tdstein Date: Fri, 18 Oct 2024 09:07:41 -0400 Subject: [PATCH 18/31] feat: add jobs --- src/posit/connect/content.py | 3 +- src/posit/connect/jobs.py | 73 ++++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 src/posit/connect/jobs.py diff --git a/src/posit/connect/content.py b/src/posit/connect/content.py index bd0f4ede..7b1b2087 100644 --- a/src/posit/connect/content.py +++ b/src/posit/connect/content.py @@ -10,6 +10,7 @@ from . import tasks from .bundles import Bundles from .env import EnvVars +from .jobs import JobsMixin from .oauth.associations import ContentItemAssociations from .permissions import Permissions from .resources import Resource, ResourceParameters, Resources @@ -32,7 +33,7 @@ class ContentItemOwner(Resource): pass -class ContentItem(VanityMixin, Resource): +class ContentItem(JobsMixin, VanityMixin, Resource): def __getitem__(self, key: Any) -> Any: v = super().__getitem__(key) if key == "owner" and isinstance(v, dict): diff --git a/src/posit/connect/jobs.py b/src/posit/connect/jobs.py new file mode 100644 index 00000000..8a6d63f8 --- /dev/null +++ b/src/posit/connect/jobs.py @@ -0,0 +1,73 @@ +from typing import List, Sequence, TypedDict + +from typing_extensions import Required, Unpack + +from .resources import Resource, ResourceParameters, Resources + + +class Job(Resource): + pass + + +class Jobs(Resources, Sequence[Job]): + """A collection of jobs.""" + + def __init__(self, params, endpoint): + super().__init__(params) + self._endpoint = endpoint + self._cache = None + + @property + def _data(self) -> List[Job]: + if self._cache: + return self._cache + + response = self.params.session.get(self._endpoint) + results = response.json() + self._cache = [Job(self.params, **result) for result in results] + return self._cache + + def __getitem__(self, index): + """Retrieve an item or slice from the sequence.""" + return self._data[index] + + def __len__(self): + """Return the length of the sequence.""" + return len(self._data) + + def __repr__(self): + """Return the string representation of the sequence.""" + return f"Jobs({', '.join(map(str, self._data))})" + + def count(self, value): + """Return the number of occurrences of a value in the sequence.""" + return self._data.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._data) + return self._data.index(value, start, stop) + + def reload(self) -> "Jobs": + """Unload the cached jobs. + + Forces the next access, if any, to query the jobs from the Connect server. + """ + self._cache = None + return self + + +class JobsMixin(Resource): + """Mixin class to add a jobs attribute to a resource.""" + + class HasGuid(TypedDict): + """Has a guid.""" + + guid: Required[str] + + def __init__(self, params: ResourceParameters, **kwargs: Unpack[HasGuid]): + super().__init__(params, **kwargs) + uid = kwargs["guid"] + endpoint = self.params.url + f"v1/content/{uid}/jobs" + self.jobs = Jobs(self.params, endpoint) From 9c3d6dd0bdae71a0e1d25e7ba12a7130907021da Mon Sep 17 00:00:00 2001 From: tdstein Date: Tue, 22 Oct 2024 11:05:50 -0400 Subject: [PATCH 19/31] --wip-- [skip ci] --- src/posit/connect/context.py | 4 +- src/posit/connect/jobs.py | 267 +++++++++++++++++++++++++++- src/posit/connect/resources.py | 38 ++++ src/posit/connect/vanities.py | 2 +- tests/posit/connect/test_content.py | 191 +------------------- 5 files changed, 302 insertions(+), 200 deletions(-) diff --git a/src/posit/connect/context.py b/src/posit/connect/context.py index c93fe7b0..8fbbe48c 100644 --- a/src/posit/connect/context.py +++ b/src/posit/connect/context.py @@ -4,6 +4,8 @@ import requests from packaging.version import Version +from .urls import Url + def requires(version: str): def decorator(func): @@ -22,7 +24,7 @@ def wrapper(instance: ContextManager, *args, **kwargs): class Context(dict): - def __init__(self, session: requests.Session, url: str): + def __init__(self, session: requests.Session, url: Url): self.session = session self.url = url diff --git a/src/posit/connect/jobs.py b/src/posit/connect/jobs.py index 8a6d63f8..d7ebf71e 100644 --- a/src/posit/connect/jobs.py +++ b/src/posit/connect/jobs.py @@ -1,20 +1,132 @@ -from typing import List, Sequence, TypedDict +from typing import List, Literal, Optional, Sequence, TypedDict, overload -from typing_extensions import Required, Unpack +from typing_extensions import NotRequired, Required, Unpack -from .resources import Resource, ResourceParameters, Resources +from .errors import ClientError +from .resources import FinderMethods, Resource, ResourceParameters, Resources + +JobTag = Literal[ + "unknown", + "build_report", + "build_site", + "build_jupyter", + "packrat_restore", + "python_restore", + "configure_report", + "run_app", + "run_api", + "run_tensorflow", + "run_python_api", + "run_dash_app", + "run_streamlit", + "run_bokeh_app", + "run_fastapi_app", + "run_pyshiny_app", + "render_shiny", + "run_voila_app", + "testing", + "git", + "val_py_ext_pkg", + "val_r_ext_pkg", + "val_r_install", +] class Job(Resource): - pass + 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.""" -class Jobs(Resources, Sequence[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.""" + + 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, /, params, endpoint, **kwargs: Unpack[_Job]): + super().__init__(params, **kwargs) + key = kwargs["key"] + self._endpoint = endpoint + key + + def destroy(self) -> None: + """Destroy the job. + + Submit a request to kill the job. + + Warnings + -------- + This operation is irreversible. + + Note + ---- + This action requires administrator, owner, or collaborator privileges. + """ + self.params.session.delete(self._endpoint) + + +class Jobs(FinderMethods[Job], Sequence[Job], Resources): """A collection of jobs.""" def __init__(self, params, endpoint): - super().__init__(params) - self._endpoint = endpoint + super().__init__(Job, params, endpoint) + self._endpoint = endpoint + "jobs" self._cache = None @property @@ -24,7 +136,7 @@ def _data(self) -> List[Job]: response = self.params.session.get(self._endpoint) results = response.json() - self._cache = [Job(self.params, **result) for result in results] + self._cache = [Job(self.params, self._endpoint, **result) for result in results] return self._cache def __getitem__(self, index): @@ -49,6 +161,143 @@ def index(self, value, start=0, stop=None): stop = len(self._data) return self._data.index(value, start, stop) + class _FindByRequest(TypedDict, total=False): + # Identifiers + id: NotRequired[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.""" + + # 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.""" + + # 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.""" + + # 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.""" + + 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. + + Parameters + ---------- + id : str, not required + A unique identifier for the job. + ppid : Optional[str], not required + Identifier of the parent process. + pid : str, not required + Identifier of the process running the job. + key : str, not required + A unique key to identify this job. + remote_id : Optional[str], not required + Identifier for off-host execution configurations. + app_id : str, not required + Identifier of the parent content associated with the job. + variant_id : str, not required + Identifier of the variant responsible for the job. + bundle_id : str, not required + Identifier of the content bundle linked to the job. + start_time : str, not required + RFC3339 timestamp indicating when the job started. + end_time : Optional[str], not required + RFC3339 timestamp indicating when the job finished. + last_heartbeat_time : str, not required + RFC3339 timestamp of the last recorded activity for the job. + queued_time : Optional[str], not required + RFC3339 timestamp when the job was added to the queue. + status : int, not required + Current status. Options are 0 (Active), 1 (Finished), and 2 (Finalized) + exit_code : Optional[int], not required + The job's exit code, available after completion. + hostname : str, not required + Name of the node processing the job. + cluster : Optional[str], not required + Location where the job runs, either 'Local' or the cluster name. + image : Optional[str], not required + Location of the content in clustered environments. + run_as : str, not required + UNIX user responsible for executing the job. + queue_name : Optional[str], not required + Name of the queue processing the job, relevant for scheduled reports. + tag : JobTag, not required + 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. + + Returns + ------- + Optional[Job] + """ + ... + + @overload + def find_by(self, **conditions): ... + + def find_by(self, **conditions): + if "key" in conditions and self._cache is None: + key = conditions["key"] + try: + return self.find(key) + except ClientError as e: + if e.http_status == 404: + return None + raise e + + return super().find_by(**conditions) + def reload(self) -> "Jobs": """Unload the cached jobs. @@ -69,5 +318,5 @@ class HasGuid(TypedDict): def __init__(self, params: ResourceParameters, **kwargs: Unpack[HasGuid]): super().__init__(params, **kwargs) uid = kwargs["guid"] - endpoint = self.params.url + f"v1/content/{uid}/jobs" + endpoint = self.params.url + f"v1/content/{uid}" self.jobs = Jobs(self.params, endpoint) diff --git a/src/posit/connect/resources.py b/src/posit/connect/resources.py index be1ef7b7..46fcd207 100644 --- a/src/posit/connect/resources.py +++ b/src/posit/connect/resources.py @@ -1,5 +1,7 @@ import warnings +from abc import ABC, abstractmethod from dataclasses import dataclass +from typing import Any, Generic, List, Optional, Type, TypeVar import requests @@ -43,3 +45,39 @@ def update(self, *args, **kwargs): class Resources: def __init__(self, params: ResourceParameters) -> None: self.params = params + + +T = TypeVar("T", bound=Resource) + + +class FinderMethods( + Generic[T], + ABC, + Resources, +): + def __init__(self, cls: Type[T], params, endpoint): + super().__init__(params) + self._cls = cls + self._endpoint = endpoint + + @property + @abstractmethod + def _data(self) -> List[T]: + raise NotImplementedError() + + def find(self, uid): + endpoint = self._endpoint + str(uid) + response = self.params.session.get(endpoint) + result = response.json() + return self._cls(self.params, endpoint=self._endpoint, **result) + + def find_by(self, **conditions: Any) -> Optional[T]: + """Finds the first record matching the specified conditions. + + There is no implied ordering so if order matters, you should specify it yourself. + + Returns + ------- + Optional[T] + """ + return next((v for v in self._data if v.items() >= conditions.items()), None) diff --git a/src/posit/connect/vanities.py b/src/posit/connect/vanities.py index a13d0282..571dccef 100644 --- a/src/posit/connect/vanities.py +++ b/src/posit/connect/vanities.py @@ -201,7 +201,7 @@ class CreateVanityRequest(TypedDict, total=False): """A request schema for creating a vanity.""" path: Required[str] - """The vanity path (.e.g, 'my-dashboard')""" + """The vanity path (e.g., 'my-dashboard')""" force: NotRequired[bool] """Whether to force creation of the vanity""" diff --git a/tests/posit/connect/test_content.py b/tests/posit/connect/test_content.py index ea857581..e18d242c 100644 --- a/tests/posit/connect/test_content.py +++ b/tests/posit/connect/test_content.py @@ -1,199 +1,12 @@ -from unittest import mock - import pytest import responses from responses import matchers from posit.connect.client import Client -from posit.connect.content import ( - ContentItem, - ContentItemOAuth, - ContentItemOwner, -) -from posit.connect.oauth.associations import ContentItemAssociations -from posit.connect.permissions import Permissions from .api import load_mock # type: ignore -class TestContentOwnerAttributes: - @classmethod - def setup_class(cls): - guid = "20a79ce3-6e87-4522-9faf-be24228800a4" - fake_item = load_mock(f"v1/users/{guid}.json") - cls.item = ContentItemOwner(mock.Mock(), **fake_item) - - def test_guid(self): - assert self.item.guid == "20a79ce3-6e87-4522-9faf-be24228800a4" - - def test_username(self): - assert self.item.username == "carlos12" - - def test_first_name(self): - assert self.item.first_name == "Carlos" - - def test_last_name(self): - assert self.item.last_name == "User" - - -class TestContentItemAttributes: - @classmethod - def setup_class(cls): - guid = "f2f37341-e21d-3d80-c698-a935ad614066" - fake_item = load_mock(f"v1/content/{guid}.json") - cls.item = ContentItem(mock.Mock(), **fake_item) - - def test_id(self): - assert self.item.id == "8274" - - def test_guid(self): - assert self.item.guid == "f2f37341-e21d-3d80-c698-a935ad614066" - - def test_name(self): - assert self.item.name == "Performance-Data-1671216053560" - - def test_title(self): - assert self.item.title == "Performance Data" - - def test_description(self): - assert self.item.description == "" - - def test_access_type(self): - assert self.item.access_type == "logged_in" - - def test_connection_timeout(self): - assert self.item.connection_timeout is None - - def test_read_timeout(self): - assert self.item.read_timeout is None - - def test_init_timeout(self): - assert self.item.init_timeout is None - - def test_idle_timeout(self): - assert self.item.idle_timeout is None - - def test_max_processes(self): - assert self.item.max_processes is None - - def test_min_processes(self): - assert self.item.min_processes is None - - def test_max_conns_per_process(self): - assert self.item.max_conns_per_process is None - - def test_load_factor(self): - assert self.item.load_factor is None - - def test_cpu_request(self): - assert self.item.cpu_request is None - - def test_cpu_limit(self): - assert self.item.cpu_limit is None - - def test_memory_request(self): - assert self.item.memory_request is None - - def test_memory_limit(self): - assert self.item.memory_limit is None - - def test_amd_gpu_limit(self): - assert self.item.amd_gpu_limit is None - - def test_nvidia_gpu_limit(self): - assert self.item.nvidia_gpu_limit is None - - def test_created_time(self): - assert self.item.created_time == "2022-12-16T18:40:53Z" - - def test_last_deployed_time(self): - assert self.item.last_deployed_time == "2024-02-24T09:56:30Z" - - def test_bundle_id(self): - assert self.item.bundle_id == "401171" - - def test_app_mode(self): - assert self.item.app_mode == "quarto-static" - - def test_content_category(self): - assert self.item.content_category == "" - - def test_parameterized(self): - assert self.item.parameterized is False - - def test_cluster_name(self): - assert self.item.cluster_name == "Local" - - def test_image_name(self): - assert self.item.image_name is None - - def test_default_image_name(self): - assert self.item.default_image_name is None - - def test_default_r_environment_management(self): - assert self.item.default_r_environment_management is None - - def test_default_py_environment_management(self): - assert self.item.default_py_environment_management is None - - def test_service_account_name(self): - assert self.item.service_account_name is None - - def test_r_version(self): - assert self.item.r_version is None - - def test_r_environment_management(self): - assert self.item.r_environment_management is None - - def test_py_version(self): - assert self.item.py_version == "3.9.17" - - def test_py_environment_management(self): - assert self.item.py_environment_management is True - - def test_quarto_version(self): - assert self.item.quarto_version == "1.3.340" - - def test_run_as(self): - assert self.item.run_as is None - - def test_run_as_current_user(self): - assert self.item.run_as_current_user is False - - def test_owner_guid(self): - assert self.item.owner_guid == "20a79ce3-6e87-4522-9faf-be24228800a4" - - def test_content_url(self): - assert ( - self.item.content_url - == "https://connect.example/content/f2f37341-e21d-3d80-c698-a935ad614066/" - ) - - def test_dashboard_url(self): - assert ( - self.item.dashboard_url - == "https://connect.example/connect/#/apps/f2f37341-e21d-3d80-c698-a935ad614066" - ) - - def test_app_role(self): - assert self.item.app_role == "viewer" - - def test_owner(self): - assert "owner" not in self.item - - def test_permissions(self): - assert isinstance(self.item.permissions, Permissions) - - def test_oauth(self): - assert isinstance(self.item.oauth, ContentItemOAuth) - - def test_oauth_associations(self): - assert isinstance(self.item.oauth.associations, ContentItemAssociations) - - def test_tags(self): - assert self.item.tags is None - - class TestContentItemGetContentOwner: @responses.activate def test_owner(self): @@ -211,11 +24,11 @@ def test_owner(self): c = Client("https://connect.example", "12345") item = c.content.get("f2f37341-e21d-3d80-c698-a935ad614066") owner = item.owner - assert owner.guid == "20a79ce3-6e87-4522-9faf-be24228800a4" + assert owner["guid"] == "20a79ce3-6e87-4522-9faf-be24228800a4" # load a second time, assert tha owner is loaded from cached result owner = item.owner - assert owner.guid == "20a79ce3-6e87-4522-9faf-be24228800a4" + assert owner["guid"] == "20a79ce3-6e87-4522-9faf-be24228800a4" assert mock_user_get.call_count == 1 From 901938620e21d781eefbcf3c2126afea5b29f12d Mon Sep 17 00:00:00 2001 From: tdstein Date: Tue, 22 Oct 2024 21:26:13 -0400 Subject: [PATCH 20/31] refactor: introduce the active pattern --- src/posit/connect/content.py | 5 ++ src/posit/connect/context.py | 2 +- src/posit/connect/jobs.py | 89 +++++++++------------------------- src/posit/connect/resources.py | 80 ++++++++++++++++++++++++------ 4 files changed, 92 insertions(+), 84 deletions(-) diff --git a/src/posit/connect/content.py b/src/posit/connect/content.py index 7b1b2087..27142d25 100644 --- a/src/posit/connect/content.py +++ b/src/posit/connect/content.py @@ -9,6 +9,7 @@ from . import tasks from .bundles import Bundles +from .context import Context from .env import EnvVars from .jobs import JobsMixin from .oauth.associations import ContentItemAssociations @@ -34,6 +35,10 @@ class ContentItemOwner(Resource): class ContentItem(JobsMixin, VanityMixin, Resource): + def __init__(self, /, params: ResourceParameters, **kwargs): + ctx = Context(params.session, params.url) + super().__init__(ctx, **kwargs) + def __getitem__(self, key: Any) -> Any: v = super().__getitem__(key) if key == "owner" and isinstance(v, dict): diff --git a/src/posit/connect/context.py b/src/posit/connect/context.py index 8fbbe48c..f8ef13b2 100644 --- a/src/posit/connect/context.py +++ b/src/posit/connect/context.py @@ -40,7 +40,7 @@ def version(self) -> Optional[str]: return value @version.setter - def version(self, value: str): + def version(self, value): self["version"] = value diff --git a/src/posit/connect/jobs.py b/src/posit/connect/jobs.py index d7ebf71e..66b28b6e 100644 --- a/src/posit/connect/jobs.py +++ b/src/posit/connect/jobs.py @@ -1,9 +1,8 @@ -from typing import List, Literal, Optional, Sequence, TypedDict, overload +from typing import Literal, Optional, TypedDict, overload from typing_extensions import NotRequired, Required, Unpack -from .errors import ClientError -from .resources import FinderMethods, Resource, ResourceParameters, Resources +from .resources import Active, ActiveFinderMethods, Resource JobTag = Literal[ "unknown", @@ -32,7 +31,7 @@ ] -class Job(Resource): +class Job(Active): class _Job(TypedDict): # Identifiers id: Required[str] @@ -100,10 +99,12 @@ class _Job(TypedDict): 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, /, params, endpoint, **kwargs: Unpack[_Job]): + def __init__(self, /, params, **kwargs: Unpack[_Job]): super().__init__(params, **kwargs) - key = kwargs["key"] - self._endpoint = endpoint + key + + @property + def _endpoint(self) -> str: + return self._ctx.url + f"v1/content/{self['app_id']}/jobs/{self['key']}" def destroy(self) -> None: """Destroy the job. @@ -118,48 +119,21 @@ def destroy(self) -> None: ---- This action requires administrator, owner, or collaborator privileges. """ - self.params.session.delete(self._endpoint) + self._ctx.session.delete(self._endpoint) -class Jobs(FinderMethods[Job], Sequence[Job], Resources): +class Jobs(ActiveFinderMethods[Job]): """A collection of jobs.""" - def __init__(self, params, endpoint): - super().__init__(Job, params, endpoint) - self._endpoint = endpoint + "jobs" - self._cache = None - - @property - def _data(self) -> List[Job]: - if self._cache: - return self._cache - - response = self.params.session.get(self._endpoint) - results = response.json() - self._cache = [Job(self.params, self._endpoint, **result) for result in results] - return self._cache - - def __getitem__(self, index): - """Retrieve an item or slice from the sequence.""" - return self._data[index] - - def __len__(self): - """Return the length of the sequence.""" - return len(self._data) + _uid = "key" - def __repr__(self): - """Return the string representation of the sequence.""" - return f"Jobs({', '.join(map(str, self._data))})" + def __init__(self, cls, ctx, parent: Active): + super().__init__(cls, ctx) + self._parent = parent - def count(self, value): - """Return the number of occurrences of a value in the sequence.""" - return self._data.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._data) - return self._data.index(value, start, stop) + @property + def _endpoint(self) -> str: + return self._ctx.url + f"v1/content/{self._parent['guid']}/jobs" class _FindByRequest(TypedDict, total=False): # Identifiers @@ -286,28 +260,11 @@ def find_by(self, **conditions: Unpack[_FindByRequest]) -> Optional[Job]: @overload def find_by(self, **conditions): ... - def find_by(self, **conditions): - if "key" in conditions and self._cache is None: - key = conditions["key"] - try: - return self.find(key) - except ClientError as e: - if e.http_status == 404: - return None - raise e - + def find_by(self, **conditions) -> Optional[Job]: return super().find_by(**conditions) - def reload(self) -> "Jobs": - """Unload the cached jobs. - Forces the next access, if any, to query the jobs from the Connect server. - """ - self._cache = None - return self - - -class JobsMixin(Resource): +class JobsMixin(Active, Resource): """Mixin class to add a jobs attribute to a resource.""" class HasGuid(TypedDict): @@ -315,8 +272,6 @@ class HasGuid(TypedDict): guid: Required[str] - def __init__(self, params: ResourceParameters, **kwargs: Unpack[HasGuid]): - super().__init__(params, **kwargs) - uid = kwargs["guid"] - endpoint = self.params.url + f"v1/content/{uid}" - self.jobs = Jobs(self.params, endpoint) + def __init__(self, ctx, **kwargs): + super().__init__(ctx, **kwargs) + self.jobs = Jobs(Job, ctx, self) diff --git a/src/posit/connect/resources.py b/src/posit/connect/resources.py index 46fcd207..18e70594 100644 --- a/src/posit/connect/resources.py +++ b/src/posit/connect/resources.py @@ -1,10 +1,13 @@ +import posixpath import warnings from abc import ABC, abstractmethod from dataclasses import dataclass -from typing import Any, Generic, List, Optional, Type, TypeVar +from typing import Any, Generic, List, Optional, Sequence, Type, TypeVar import requests +from posit.connect.context import Context + from .urls import Url @@ -47,29 +50,74 @@ def __init__(self, params: ResourceParameters) -> None: self.params = params -T = TypeVar("T", bound=Resource) +T = TypeVar("T", bound="Active", covariant=True) + + +class Active(Resource): + def __init__(self, ctx: Context, **kwargs): + params = ResourceParameters(ctx.session, ctx.url) + super().__init__(params, **kwargs) + self._ctx = ctx -class FinderMethods( - Generic[T], - ABC, - Resources, -): - def __init__(self, cls: Type[T], params, endpoint): - super().__init__(params) +class ActiveReader(ABC, Generic[T], Sequence[T]): + def __init__(self, cls: Type[T], ctx: Context): + super().__init__() self._cls = cls - self._endpoint = endpoint + self._ctx = ctx + self._cache = None @property @abstractmethod - def _data(self) -> List[T]: + def _endpoint(self) -> str: raise NotImplementedError() - def find(self, uid): - endpoint = self._endpoint + str(uid) - response = self.params.session.get(endpoint) - result = response.json() - return self._cls(self.params, endpoint=self._endpoint, **result) + @property + def _data(self) -> List[T]: + if self._cache: + return self._cache + + response = self._ctx.session.get(self._endpoint) + results = response.json() + self._cache = [self._cls(self._ctx, **result) for result in results] + return self._cache + + def __getitem__(self, index): + """Retrieve an item or slice from the sequence.""" + return self._data[index] + + def __len__(self): + """Return the length of the sequence.""" + return len(self._data) + + def __str__(self): + return str(self._data) + + def __repr__(self): + return repr(self._data) + + def reload(self): + self._cache = None + return self + + +class ActiveFinderMethods(ActiveReader[T], ABC, Generic[T]): + _uid: str = "guid" + + def find(self, uid) -> T: + if self._cache: + conditions = {self._uid: uid} + result = self.find_by(**conditions) + else: + endpoint = posixpath.join(self._endpoint + uid) + response = self._ctx.session.get(endpoint) + result = response.json() + result = self._cls(self._ctx, **result) + + if not result: + raise ValueError("") + + return result def find_by(self, **conditions: Any) -> Optional[T]: """Finds the first record matching the specified conditions. From 4bfe3f8d4655aa2fdbd5bdc46277f2e2491d1b1e Mon Sep 17 00:00:00 2001 From: tdstein Date: Wed, 23 Oct 2024 12:44:06 -0400 Subject: [PATCH 21/31] add link to parent --- integration/tests/posit/connect/test_jobs.py | 29 ++++++++++++++++++++ src/posit/connect/jobs.py | 14 ++++------ src/posit/connect/resources.py | 18 ++++++++---- 3 files changed, 47 insertions(+), 14 deletions(-) create mode 100644 integration/tests/posit/connect/test_jobs.py diff --git a/integration/tests/posit/connect/test_jobs.py b/integration/tests/posit/connect/test_jobs.py new file mode 100644 index 00000000..6a95da8c --- /dev/null +++ b/integration/tests/posit/connect/test_jobs.py @@ -0,0 +1,29 @@ +from pathlib import Path + +from posit import connect + + +class TestContent: + @classmethod + def setup_class(cls): + cls.client = connect.Client() + cls.content = cls.client.content.create(name="example-quarto-minimal") + + @classmethod + def teardown_class(cls): + cls.content.delete() + assert cls.client.content.count() == 0 + + def test(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 + assert len(jobs) == 1 diff --git a/src/posit/connect/jobs.py b/src/posit/connect/jobs.py index 66b28b6e..c18a279d 100644 --- a/src/posit/connect/jobs.py +++ b/src/posit/connect/jobs.py @@ -99,12 +99,13 @@ class _Job(TypedDict): 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, /, params, **kwargs: Unpack[_Job]): - super().__init__(params, **kwargs) + def __init__(self, ctx, parent: Active, **kwargs: Unpack[_Job]): + super().__init__(ctx, parent, **kwargs) + self._parent = parent @property def _endpoint(self) -> str: - return self._ctx.url + f"v1/content/{self['app_id']}/jobs/{self['key']}" + return self._ctx.url + f"v1/content/{self._parent['guid']}/jobs/{self['key']}" def destroy(self) -> None: """Destroy the job. @@ -128,7 +129,7 @@ class Jobs(ActiveFinderMethods[Job]): _uid = "key" def __init__(self, cls, ctx, parent: Active): - super().__init__(cls, ctx) + super().__init__(cls, ctx, parent) self._parent = parent @property @@ -267,11 +268,6 @@ def find_by(self, **conditions) -> Optional[Job]: class JobsMixin(Active, Resource): """Mixin class to add a jobs attribute to a resource.""" - class HasGuid(TypedDict): - """Has a guid.""" - - guid: Required[str] - def __init__(self, ctx, **kwargs): super().__init__(ctx, **kwargs) self.jobs = Jobs(Job, ctx, self) diff --git a/src/posit/connect/resources.py b/src/posit/connect/resources.py index 18e70594..0630c9dd 100644 --- a/src/posit/connect/resources.py +++ b/src/posit/connect/resources.py @@ -2,7 +2,7 @@ import warnings from abc import ABC, abstractmethod from dataclasses import dataclass -from typing import Any, Generic, List, Optional, Sequence, Type, TypeVar +from typing import Any, Generic, List, Optional, Sequence, Type, TypeVar, overload import requests @@ -54,17 +54,19 @@ def __init__(self, params: ResourceParameters) -> None: class Active(Resource): - def __init__(self, ctx: Context, **kwargs): + def __init__(self, ctx: Context, parent: Optional["Active"] = None, **kwargs): params = ResourceParameters(ctx.session, ctx.url) super().__init__(params, **kwargs) self._ctx = ctx + self._parent = parent class ActiveReader(ABC, Generic[T], Sequence[T]): - def __init__(self, cls: Type[T], ctx: Context): + def __init__(self, cls: Type[T], ctx: Context, parent: Optional[Active] = None): super().__init__() self._cls = cls self._ctx = ctx + self._parent = parent self._cache = None @property @@ -79,9 +81,15 @@ def _data(self) -> List[T]: response = self._ctx.session.get(self._endpoint) results = response.json() - self._cache = [self._cls(self._ctx, **result) for result in results] + self._cache = [self._cls(self._ctx, self._parent, **result) for result in results] return self._cache + @overload + def __getitem__(self, index: int) -> T: ... + + @overload + def __getitem__(self, index: slice) -> Sequence[T]: ... + def __getitem__(self, index): """Retrieve an item or slice from the sequence.""" return self._data[index] @@ -112,7 +120,7 @@ def find(self, uid) -> T: endpoint = posixpath.join(self._endpoint + uid) response = self._ctx.session.get(endpoint) result = response.json() - result = self._cls(self._ctx, **result) + result = self._cls(self._ctx, self._parent, **result) if not result: raise ValueError("") From a721b6171738d8f17e60342c6841bdcdaba7801f Mon Sep 17 00:00:00 2001 From: tdstein Date: Wed, 23 Oct 2024 13:00:05 -0400 Subject: [PATCH 22/31] skip when Quarto unavailable --- integration/tests/posit/connect/test_jobs.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/integration/tests/posit/connect/test_jobs.py b/integration/tests/posit/connect/test_jobs.py index 6a95da8c..cf097073 100644 --- a/integration/tests/posit/connect/test_jobs.py +++ b/integration/tests/posit/connect/test_jobs.py @@ -1,7 +1,12 @@ from pathlib import Path +import pytest +from packaging import version + from posit import connect +from . import CONNECT_VERSION + class TestContent: @classmethod @@ -14,6 +19,10 @@ 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 From 107ee859bffa76a59e8242547633c6f21397d0de Mon Sep 17 00:00:00 2001 From: tdstein Date: Wed, 23 Oct 2024 16:39:28 -0400 Subject: [PATCH 23/31] adds unit tests --- src/posit/connect/resources.py | 2 +- .../jobs.json | 24 +++ .../jobs/tHawGvHZTosJA2Dx.json | 22 +++ tests/posit/connect/test_jobs.py | 155 ++++++++++++++++++ 4 files changed, 202 insertions(+), 1 deletion(-) create mode 100644 tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/jobs.json create mode 100644 tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/jobs/tHawGvHZTosJA2Dx.json create mode 100644 tests/posit/connect/test_jobs.py diff --git a/src/posit/connect/resources.py b/src/posit/connect/resources.py index 0630c9dd..73e73d6d 100644 --- a/src/posit/connect/resources.py +++ b/src/posit/connect/resources.py @@ -123,7 +123,7 @@ def find(self, uid) -> T: result = self._cls(self._ctx, self._parent, **result) if not result: - raise ValueError("") + raise ValueError(f"Failed to find instance where {self._uid} is '{uid}'") return result diff --git a/tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/jobs.json b/tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/jobs.json new file mode 100644 index 00000000..b497e465 --- /dev/null +++ b/tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/jobs.json @@ -0,0 +1,24 @@ +[ + { + "id": "54", + "ppid": "20253", + "pid": "20253", + "key": "tHawGvHZTosJA2Dx", + "remote_id": "S3ViZXJuZXRlczpyZW5kZXItci1tYXJrZG93bi1zaXRlLWtnODJo", + "app_id": "54", + "variant_id": "54", + "bundle_id": "54", + "start_time": "2006-01-02T15:04:05-07:00", + "end_time": "2006-01-02T15:04:05-07:00", + "last_heartbeat_time": "2006-01-02T15:04:05-07:00", + "queued_time": "2006-01-02T15:04:05-07:00", + "queue_name": "default", + "tag": "build_report", + "exit_code": 0, + "status": 0, + "hostname": "connect", + "cluster": "Kubernetes", + "image": "someorg/image:jammy", + "run_as": "rstudio-connect" + } +] diff --git a/tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/jobs/tHawGvHZTosJA2Dx.json b/tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/jobs/tHawGvHZTosJA2Dx.json new file mode 100644 index 00000000..c1ca8446 --- /dev/null +++ b/tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/jobs/tHawGvHZTosJA2Dx.json @@ -0,0 +1,22 @@ +{ + "id": "54", + "ppid": "20253", + "pid": "20253", + "key": "tHawGvHZTosJA2Dx", + "remote_id": "S3ViZXJuZXRlczpyZW5kZXItci1tYXJrZG93bi1zaXRlLWtnODJo", + "app_id": "54", + "variant_id": "54", + "bundle_id": "54", + "start_time": "2006-01-02T15:04:05-07:00", + "end_time": "2006-01-02T15:04:05-07:00", + "last_heartbeat_time": "2006-01-02T15:04:05-07:00", + "queued_time": "2006-01-02T15:04:05-07:00", + "queue_name": "default", + "tag": "build_report", + "exit_code": 0, + "status": 0, + "hostname": "connect", + "cluster": "Kubernetes", + "image": "someorg/image:jammy", + "run_as": "rstudio-connect" +} diff --git a/tests/posit/connect/test_jobs.py b/tests/posit/connect/test_jobs.py new file mode 100644 index 00000000..b923d8d8 --- /dev/null +++ b/tests/posit/connect/test_jobs.py @@ -0,0 +1,155 @@ +import pytest +import responses + +from posit.connect.client import Client + +from .api import load_mock # type: ignore + + +class TestJobsMixin: + @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/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 + + +class TestJobsFind: + @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/jobs/tHawGvHZTosJA2Dx", + json=load_mock( + "v1/content/f2f37341-e21d-3d80-c698-a935ad614066/jobs/tHawGvHZTosJA2Dx.json" + ), + ) + + c = Client("https://connect.example", "12345") + content = c.content.get("f2f37341-e21d-3d80-c698-a935ad614066") + + job = content.jobs.find("tHawGvHZTosJA2Dx") + assert job["key"] == "tHawGvHZTosJA2Dx" + + @responses.activate + def test_cached(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/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 content.jobs + job = content.jobs.find("tHawGvHZTosJA2Dx") + assert job["key"] == "tHawGvHZTosJA2Dx" + + @responses.activate + def test_miss(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/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 content.jobs + with pytest.raises(ValueError): + content.jobs.find("not-found") + + +class TestJobsFindBy: + @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/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") + + job = content.jobs.find_by(key="tHawGvHZTosJA2Dx") + assert job + 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): + 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/jobs/tHawGvHZTosJA2Dx", + json=load_mock( + "v1/content/f2f37341-e21d-3d80-c698-a935ad614066/jobs/tHawGvHZTosJA2Dx.json" + ), + ) + + responses.delete( + "https://connect.example/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/jobs/tHawGvHZTosJA2Dx", + ) + + c = Client("https://connect.example", "12345") + content = c.content.get("f2f37341-e21d-3d80-c698-a935ad614066") + + job = content.jobs.find("tHawGvHZTosJA2Dx") + job.destroy() From a070f0a943cf950c73c341e3433265a177b0fc90 Mon Sep 17 00:00:00 2001 From: tdstein Date: Wed, 23 Oct 2024 17:20:52 -0400 Subject: [PATCH 24/31] adds docstrings --- integration/tests/posit/connect/test_jobs.py | 2 +- src/posit/connect/resources.py | 206 ++++++++++++++++--- tests/posit/connect/test_jobs.py | 2 + 3 files changed, 186 insertions(+), 24 deletions(-) diff --git a/integration/tests/posit/connect/test_jobs.py b/integration/tests/posit/connect/test_jobs.py index cf097073..9617cf7e 100644 --- a/integration/tests/posit/connect/test_jobs.py +++ b/integration/tests/posit/connect/test_jobs.py @@ -8,7 +8,7 @@ from . import CONNECT_VERSION -class TestContent: +class TestJobs: @classmethod def setup_class(cls): cls.client = connect.Client() diff --git a/src/posit/connect/resources.py b/src/posit/connect/resources.py index 73e73d6d..b435f7ce 100644 --- a/src/posit/connect/resources.py +++ b/src/posit/connect/resources.py @@ -1,4 +1,3 @@ -import posixpath import warnings from abc import ABC, abstractmethod from dataclasses import dataclass @@ -6,8 +5,7 @@ import requests -from posit.connect.context import Context - +from .context import Context from .urls import Url @@ -50,19 +48,106 @@ def __init__(self, params: ResourceParameters) -> None: self.params = params -T = TypeVar("T", bound="Active", covariant=True) +class Active(Resource): + """ + A base class representing an active resource. + Extends the `Resource` class and provides additional functionality for via the session context and an optional parent resource. + + Parameters + ---------- + ctx : Context + The context object containing the session and URL for API interactions. + parent : Optional[Active], optional + An optional parent resource that establishes a hierarchical relationship, by default None. + **kwargs : dict + Additional keyword arguments passed to the parent `Resource` class. + + Attributes + ---------- + _ctx : Context + The session context. + _parent : Optional[Active] + The parent resource, if provided, which establishes a hierarchical relationship. + """ -class Active(Resource): def __init__(self, ctx: Context, parent: Optional["Active"] = None, **kwargs): + """ + Initialize the `Active` resource. + + Parameters + ---------- + ctx : Context + The context object containing session and URL for API interactions. + parent : Optional[Active], optional + An optional parent resource to establish a hierarchical relationship, by default None. + **kwargs : dict + Additional keyword arguments passed to the parent `Resource` class. + """ params = ResourceParameters(ctx.session, ctx.url) super().__init__(params, **kwargs) self._ctx = ctx self._parent = parent -class ActiveReader(ABC, Generic[T], Sequence[T]): - def __init__(self, cls: Type[T], ctx: Context, parent: Optional[Active] = None): +T_co = TypeVar("T_co", bound="Active", covariant=True) +"""A covariant type variable that is bound to the `Active` class, meaning that `T_co` must be or derive from `Active`.""" + + +class ActiveSequence(ABC, Generic[T_co], Sequence[T_co]): + """ + A sequence abstraction for any HTTP GET endpoint that returns a collection. + + It lazily fetches data on demand, caches the results, and allows for standard sequence operations like indexing and slicing. + + Parameters + ---------- + cls : Type[T_co] + The class used to represent each item in the sequence. + ctx : Context + The context object that holds the HTTP session used for sending the GET request. + parent : Optional[Active], optional + An optional parent resource to establish a nested relationship, by default None. + + Attributes + ---------- + _cls : Type[T_co] + The class used to instantiate each item in the sequence. + _ctx : Context + The context containing the HTTP session used to interact with the API. + _parent : Optional[Active] + Optional parent resource for maintaining hierarchical relationships. + _cache : Optional[List[T_co]] + Cached list of items returned from the API. Set to None initially, and populated after the first request. + + Abstract Properties + ------------------- + _endpoint : str + The API endpoint URL for the HTTP GET request. Subclasses are required to implement this property. + + Methods + ------- + _data() -> List[T_co] + Fetch and cache the data from the API. This method sends a GET request to `_endpoint`, parses the + response as JSON, and instantiates each item using `cls`. + + __getitem__(index) -> Union[T_co, Sequence[T_co]] + Retrieve an item or slice from the sequence. Indexing follows the standard Python sequence semantics. + + __len__() -> int + Return the number of items in the sequence. + + __str__() -> str + Return a string representation of the cached data. + + __repr__() -> str + Return a detailed string representation of the cached data. + + reload() -> ActiveSequence + Clear the cache and mark to reload the data from the API on the next operation. + """ + + def __init__(self, cls: Type[T_co], ctx: Context, parent: Optional[Active] = None): super().__init__() self._cls = cls self._ctx = ctx @@ -72,10 +157,33 @@ def __init__(self, cls: Type[T], ctx: Context, parent: Optional[Active] = None): @property @abstractmethod def _endpoint(self) -> str: + """ + Abstract property to define the endpoint URL for the GET request. + + Subclasses must implement this property to return the API endpoint URL that will + be queried to fetch the data. + + Returns + ------- + str + The API endpoint URL. + """ raise NotImplementedError() @property - def _data(self) -> List[T]: + def _data(self) -> List[T_co]: + """ + Fetch and cache the data from the API. + + This method sends a GET request to the `_endpoint` and parses the response as a list of JSON objects. + Each JSON object is used to instantiate an item of type `T_co` using the class specified by `_cls`. + The results are cached after the first request and reused for subsequent access unless reloaded. + + Returns + ------- + List[T_co] + A list of items of type `T_co` representing the fetched data. + """ if self._cache: return self._cache @@ -85,39 +193,85 @@ def _data(self) -> List[T]: return self._cache @overload - def __getitem__(self, index: int) -> T: ... + def __getitem__(self, index: int) -> T_co: ... @overload - def __getitem__(self, index: slice) -> Sequence[T]: ... + def __getitem__(self, index: slice) -> Sequence[T_co]: ... def __getitem__(self, index): - """Retrieve an item or slice from the sequence.""" return self._data[index] - def __len__(self): - """Return the length of the sequence.""" + def __len__(self) -> int: return len(self._data) - def __str__(self): + def __str__(self) -> str: return str(self._data) - def __repr__(self): + def __repr__(self) -> str: return repr(self._data) - def reload(self): + def reload(self) -> "ActiveSequence": + """ + Clear the cache and reload the data from the API on the next access. + + Returns + ------- + ActiveSequence + The current instance with cleared cache, ready to reload data on next access. + """ self._cache = None return self -class ActiveFinderMethods(ActiveReader[T], ABC, Generic[T]): +class ActiveFinderMethods(ActiveSequence[T_co], ABC, Generic[T_co]): + """ + Finder methods. + + Provides various finder methods for locating records in any endpoint supporting HTTP GET requests. + + Attributes + ---------- + _uid : str + The default field name used to uniquely identify records. Defaults to 'guid'. + + Methods + ------- + find(uid) -> T_co + Finds and returns a record by its unique identifier (`uid`). If a cached result exists, it searches within the cache; + otherwise, it makes a GET request to retrieve the data from the endpoint. + + find_by(**conditions: Any) -> Optional[T_co] + Finds the first record that matches the provided conditions. If no record is found, returns None. + """ + _uid: str = "guid" + """The default field name used to uniquely identify records. Defaults to 'guid'.""" + + def find(self, uid) -> T_co: + """ + Find a record by its unique identifier. + + Fetches a record either by searching the cache or by making a GET request to the endpoint. - def find(self, uid) -> T: + Parameters + ---------- + uid : Any + The unique identifier of the record. + + Returns + ------- + T_co + + Raises + ------ + ValueError + If no record is found. + """ if self._cache: conditions = {self._uid: uid} result = self.find_by(**conditions) else: - endpoint = posixpath.join(self._endpoint + uid) + endpoint = self._endpoint + uid response = self._ctx.session.get(endpoint) result = response.json() result = self._cls(self._ctx, self._parent, **result) @@ -127,13 +281,19 @@ def find(self, uid) -> T: return result - def find_by(self, **conditions: Any) -> Optional[T]: - """Finds the first record matching the specified conditions. + def find_by(self, **conditions: Any) -> Optional[T_co]: + """ + Find 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 + ---------- + **conditions : Any Returns ------- - Optional[T] + Optional[T_co] + The first record matching the conditions, or `None` if no match is found. """ return next((v for v in self._data if v.items() >= conditions.items()), None) diff --git a/tests/posit/connect/test_jobs.py b/tests/posit/connect/test_jobs.py index b923d8d8..e74d6081 100644 --- a/tests/posit/connect/test_jobs.py +++ b/tests/posit/connect/test_jobs.py @@ -105,6 +105,7 @@ def test(self): assert job assert job["key"] == "tHawGvHZTosJA2Dx" + class TestJobsReload: @responses.activate def test(self): @@ -129,6 +130,7 @@ def test(self): assert len(content.jobs) == 1 assert mock_get.call_count == 2 + class TestJobDestory: @responses.activate def test(self): From d1962711b3062583851d5d7c5cf0ab59c1f55d08 Mon Sep 17 00:00:00 2001 From: Taylor Steinberg Date: Thu, 24 Oct 2024 15:54:38 -0400 Subject: [PATCH 25/31] Update src/posit/connect/resources.py --- 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 b435f7ce..a2a1e7f3 100644 --- a/src/posit/connect/resources.py +++ b/src/posit/connect/resources.py @@ -91,7 +91,7 @@ def __init__(self, ctx: Context, parent: Optional["Active"] = None, **kwargs): T_co = TypeVar("T_co", bound="Active", covariant=True) -"""A covariant type variable that is bound to the `Active` class, meaning that `T_co` must be or derive from `Active`.""" +"""A covariant type variable that is bound to the `Active` class and must inherit from it.""" class ActiveSequence(ABC, Generic[T_co], Sequence[T_co]): From d87cfe70d2af098989aab5f543207c31bc3d25fa Mon Sep 17 00:00:00 2001 From: tdstein Date: Thu, 24 Oct 2024 17:36:38 -0400 Subject: [PATCH 26/31] applies feedback discussed in pull requests --- src/posit/connect/jobs.py | 37 +++++-- src/posit/connect/resources.py | 179 +++++++++++---------------------- 2 files changed, 89 insertions(+), 127 deletions(-) diff --git a/src/posit/connect/jobs.py b/src/posit/connect/jobs.py index c18a279d..acaf0765 100644 --- a/src/posit/connect/jobs.py +++ b/src/posit/connect/jobs.py @@ -2,7 +2,7 @@ from typing_extensions import NotRequired, Required, Unpack -from .resources import Active, ActiveFinderMethods, Resource +from .resources import Active, ActiveFinderMethods, ActiveSequence, Resource JobTag = Literal[ "unknown", @@ -123,22 +123,41 @@ def destroy(self) -> None: self._ctx.session.delete(self._endpoint) -class Jobs(ActiveFinderMethods[Job]): - """A collection of jobs.""" +class Jobs( + ActiveFinderMethods[Job], + ActiveSequence[Job], +): + def __init__(self, ctx, parent: Active, uid="key"): + """A collection of jobs. - _uid = "key" - - def __init__(self, cls, ctx, parent: Active): - super().__init__(cls, ctx, parent) + Parameters + ---------- + ctx : Context + The context containing the HTTP session used to interact with the API. + parent : Active + Parent resource for maintaining hierarchical relationships + uid : str, optional + The default field name used to uniquely identify records, by default "key" + """ + super().__init__(ctx, parent, uid) self._parent = parent @property def _endpoint(self) -> str: return self._ctx.url + f"v1/content/{self._parent['guid']}/jobs" + def _create_instance(self, **kwargs) -> Job: + """Creates a `Job` instance. + + Returns + ------- + Job + """ + return Job(self._ctx, self._parent, **kwargs) + class _FindByRequest(TypedDict, total=False): # Identifiers - id: NotRequired[str] + id: Required[str] """A unique identifier for the job.""" ppid: NotRequired[Optional[str]] @@ -270,4 +289,4 @@ class JobsMixin(Active, Resource): def __init__(self, ctx, **kwargs): super().__init__(ctx, **kwargs) - self.jobs = Jobs(Job, ctx, self) + self.jobs = Jobs(ctx, self) diff --git a/src/posit/connect/resources.py b/src/posit/connect/resources.py index a2a1e7f3..3d652281 100644 --- a/src/posit/connect/resources.py +++ b/src/posit/connect/resources.py @@ -1,9 +1,10 @@ import warnings from abc import ABC, abstractmethod from dataclasses import dataclass -from typing import Any, Generic, List, Optional, Sequence, Type, TypeVar, overload +from typing import Any, Generic, List, Optional, Sequence, TypeVar, overload import requests +from typing_extensions import Self from .context import Context from .urls import Url @@ -48,39 +49,18 @@ def __init__(self, params: ResourceParameters) -> None: self.params = params -class Active(Resource): - """ - A base class representing an active resource. - - Extends the `Resource` class and provides additional functionality for via the session context and an optional parent resource. - - Parameters - ---------- - ctx : Context - The context object containing the session and URL for API interactions. - parent : Optional[Active], optional - An optional parent resource that establishes a hierarchical relationship, by default None. - **kwargs : dict - Additional keyword arguments passed to the parent `Resource` class. - - Attributes - ---------- - _ctx : Context - The session context. - _parent : Optional[Active] - The parent resource, if provided, which establishes a hierarchical relationship. - """ - +class Active(ABC, Resource): def __init__(self, ctx: Context, parent: Optional["Active"] = None, **kwargs): - """ - Initialize the `Active` resource. + """A base class representing an active resource. + + Extends the `Resource` class and provides additional functionality for via the session context and an optional parent resource. Parameters ---------- ctx : Context - The context object containing session and URL for API interactions. + The context object containing the session and URL for API interactions. parent : Optional[Active], optional - An optional parent resource to establish a hierarchical relationship, by default None. + An optional parent resource that establishes a hierarchical relationship, by default None. **kwargs : dict Additional keyword arguments passed to the parent `Resource` class. """ @@ -90,69 +70,27 @@ def __init__(self, ctx: Context, parent: Optional["Active"] = None, **kwargs): self._parent = parent -T_co = TypeVar("T_co", bound="Active", covariant=True) -"""A covariant type variable that is bound to the `Active` class and must inherit from it.""" +T = TypeVar("T", bound="Active") +"""A type variable that is bound to the `Active` class""" -class ActiveSequence(ABC, Generic[T_co], Sequence[T_co]): - """ - A sequence abstraction for any HTTP GET endpoint that returns a collection. - - It lazily fetches data on demand, caches the results, and allows for standard sequence operations like indexing and slicing. +class ActiveSequence(ABC, Generic[T], Sequence[T]): + def __init__(self, ctx: Context, parent: Optional[Active] = None): + """A sequence abstraction for any HTTP GET endpoint that returns a collection. - Parameters - ---------- - cls : Type[T_co] - The class used to represent each item in the sequence. - ctx : Context - The context object that holds the HTTP session used for sending the GET request. - parent : Optional[Active], optional - An optional parent resource to establish a nested relationship, by default None. + It lazily fetches data on demand, caches the results, and allows for standard sequence operations like indexing and slicing. - Attributes - ---------- - _cls : Type[T_co] - The class used to instantiate each item in the sequence. - _ctx : Context - The context containing the HTTP session used to interact with the API. - _parent : Optional[Active] - Optional parent resource for maintaining hierarchical relationships. - _cache : Optional[List[T_co]] - Cached list of items returned from the API. Set to None initially, and populated after the first request. - - Abstract Properties - ------------------- - _endpoint : str - The API endpoint URL for the HTTP GET request. Subclasses are required to implement this property. - - Methods - ------- - _data() -> List[T_co] - Fetch and cache the data from the API. This method sends a GET request to `_endpoint`, parses the - response as JSON, and instantiates each item using `cls`. - - __getitem__(index) -> Union[T_co, Sequence[T_co]] - Retrieve an item or slice from the sequence. Indexing follows the standard Python sequence semantics. - - __len__() -> int - Return the number of items in the sequence. - - __str__() -> str - Return a string representation of the cached data. - - __repr__() -> str - Return a detailed string representation of the cached data. - - reload() -> ActiveSequence - Clear the cache and mark to reload the data from the API on the next operation. - """ - - def __init__(self, cls: Type[T_co], ctx: Context, parent: Optional[Active] = None): + Parameters + ---------- + ctx : Context + The context object that holds the HTTP session used for sending the GET request. + parent : Optional[Active], optional + An optional parent resource to establish a nested relationship, by default None. + """ super().__init__() - self._cls = cls self._ctx = ctx self._parent = parent - self._cache = None + self._cache: Optional[List[T]] = None @property @abstractmethod @@ -171,32 +109,32 @@ def _endpoint(self) -> str: raise NotImplementedError() @property - def _data(self) -> List[T_co]: + def _data(self) -> List[T]: """ Fetch and cache the data from the API. This method sends a GET request to the `_endpoint` and parses the response as a list of JSON objects. - Each JSON object is used to instantiate an item of type `T_co` using the class specified by `_cls`. + Each JSON object is used to instantiate an item of type `T` using the class specified by `_cls`. The results are cached after the first request and reused for subsequent access unless reloaded. Returns ------- - List[T_co] - A list of items of type `T_co` representing the fetched data. + List[T] + A list of items of type `T` representing the fetched data. """ if self._cache: return self._cache response = self._ctx.session.get(self._endpoint) results = response.json() - self._cache = [self._cls(self._ctx, self._parent, **result) for result in results] + self._cache = [self._create_instance(**result) for result in results] return self._cache @overload - def __getitem__(self, index: int) -> T_co: ... + def __getitem__(self, index: int) -> T: ... @overload - def __getitem__(self, index: slice) -> Sequence[T_co]: ... + def __getitem__(self, index: slice) -> Sequence[T]: ... def __getitem__(self, index): return self._data[index] @@ -210,7 +148,17 @@ def __str__(self) -> str: def __repr__(self) -> str: return repr(self._data) - def reload(self) -> "ActiveSequence": + @abstractmethod + def _create_instance(self, **kwargs) -> T: + """Create an instance of 'T'. + + Returns + ------- + T + """ + raise NotImplementedError() + + def reload(self) -> Self: """ Clear the cache and reload the data from the API on the next access. @@ -223,31 +171,25 @@ def reload(self) -> "ActiveSequence": return self -class ActiveFinderMethods(ActiveSequence[T_co], ABC, Generic[T_co]): - """ - Finder methods. +class ActiveFinderMethods(ActiveSequence[T], ABC, Generic[T]): + def __init__(self, ctx: Context, parent: Optional[Active] = None, uid: str = "guid"): + """Finder methods. - Provides various finder methods for locating records in any endpoint supporting HTTP GET requests. + Provides various finder methods for locating records in any endpoint supporting HTTP GET requests. - Attributes - ---------- - _uid : str - The default field name used to uniquely identify records. Defaults to 'guid'. - - Methods - ------- - find(uid) -> T_co - Finds and returns a record by its unique identifier (`uid`). If a cached result exists, it searches within the cache; - otherwise, it makes a GET request to retrieve the data from the endpoint. - - find_by(**conditions: Any) -> Optional[T_co] - Finds the first record that matches the provided conditions. If no record is found, returns None. - """ - - _uid: str = "guid" - """The default field name used to uniquely identify records. Defaults to 'guid'.""" + Parameters + ---------- + ctx : Context + The context containing the HTTP session used to interact with the API. + parent : Optional[Active], optional + Optional parent resource for maintaining hierarchical relationships, by default None + uid : str, optional + The default field name used to uniquely identify records, by default "guid" + """ + super().__init__(ctx, parent) + self._uid = uid - def find(self, uid) -> T_co: + def find(self, uid) -> T: """ Find a record by its unique identifier. @@ -260,13 +202,14 @@ def find(self, uid) -> T_co: Returns ------- - T_co + T Raises ------ ValueError If no record is found. """ + # todo - add some more comments about this if self._cache: conditions = {self._uid: uid} result = self.find_by(**conditions) @@ -274,14 +217,14 @@ def find(self, uid) -> T_co: endpoint = self._endpoint + uid response = self._ctx.session.get(endpoint) result = response.json() - result = self._cls(self._ctx, self._parent, **result) + result = self._create_instance(**result) if not result: raise ValueError(f"Failed to find instance where {self._uid} is '{uid}'") return result - def find_by(self, **conditions: Any) -> Optional[T_co]: + def find_by(self, **conditions: Any) -> Optional[T]: """ Find the first record matching the specified conditions. @@ -293,7 +236,7 @@ def find_by(self, **conditions: Any) -> Optional[T_co]: Returns ------- - Optional[T_co] + Optional[T] The first record matching the conditions, or `None` if no match is found. """ return next((v for v in self._data if v.items() >= conditions.items()), None) From 7eeb054bb61c562c2ac8347505d7d2abb797e94f Mon Sep 17 00:00:00 2001 From: Taylor Steinberg Date: Wed, 30 Oct 2024 13:49:30 -0400 Subject: [PATCH 27/31] refactor: wrap cache interactions (#318) --- src/posit/connect/resources.py | 136 ++++++++++++++++----------------- 1 file changed, 64 insertions(+), 72 deletions(-) diff --git a/src/posit/connect/resources.py b/src/posit/connect/resources.py index 3cd6aef0..c3d2f282 100644 --- a/src/posit/connect/resources.py +++ b/src/posit/connect/resources.py @@ -85,53 +85,80 @@ def __init__(self, ctx: Context, path: str, pathinfo: str = "", /, **attributes) class ActiveSequence(ABC, Generic[T], Sequence[T]): + """A sequence for any HTTP endpoint that returns a collection.""" + def __init__(self, ctx: Context, path: str, pathinfo: str = "", uid: str = "guid"): - """A sequence abstraction for any HTTP GET endpoint that returns a collection. + """A sequence abstraction for any HTTP GET endpoint that returns a collection.""" + super().__init__() + self._ctx: Context = ctx + self._path: str = posixpath.join(path, pathinfo) + self._uid: str = uid + self._cache: Optional[List[T]] = None - It lazily fetches data on demand, caches the results, and allows for standard sequence operations like indexing and slicing. + @abstractmethod + def _create_instance(self, path: str, pathinfo: str, /, **kwargs: Any) -> T: + """Create an instance of 'T'.""" + raise NotImplementedError() - Attributes - ---------- - _ctx : Context - The context object containing the session and URL for API interactions - _path : str - The HTTP path for the collection endpoint. - _uid : str - The field name used to uniquely identify records. - _cache: Optional[List[T]] + def cached(self) -> bool: + """Returns True if the collection is cached. + + Returns + ------- + bool + + See Also + -------- + reload """ - super().__init__() - self._ctx = ctx - self._path = posixpath.join(path, pathinfo) - self._uid = uid - self._cache: Optional[List[T]] = None + return self._cache is not None + + def reload(self) -> Self: + """Reloads the collection from Connect. - def _get_or_fetch(self) -> List[T]: + Returns + ------- + Self """ - Fetch and cache the data from the API. + self._cache = None + return self + + def _fetch(self) -> List[T]: + """Fetch the collection. - This method sends a GET request to the `_endpoint` and parses the response as a list of JSON objects. - Each JSON object is used to instantiate an item of type `T` using the class specified by `_cls`. - The results are cached after the first request and reused for subsequent access unless reloaded. + Fetches the collection directly from Connect. This operation does not effect the cache state. Returns ------- List[T] - A list of items of type `T` representing the fetched data. """ - if self._cache is not None: - return self._cache - endpoint = self._ctx.url + self._path response = self._ctx.session.get(endpoint) results = response.json() + return [self._to_instance(result) for result in results] - self._cache = [] - for result in results: - uid = result[self._uid] - instance = self._create_instance(self._path, uid, **result) - self._cache.append(instance) + def _to_instance(self, result: dict) -> T: + """Converts a result into an instance of T.""" + uid = result[self._uid] + return self._create_instance(self._path, uid, **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 @@ -141,42 +168,16 @@ def __getitem__(self, index: int) -> T: ... def __getitem__(self, index: slice) -> Sequence[T]: ... def __getitem__(self, index): - data = self._get_or_fetch() - return data[index] + return self._data[index] def __len__(self) -> int: - data = self._get_or_fetch() - return len(data) + return len(self._data) def __str__(self) -> str: - data = self._get_or_fetch() - return str(data) + return str(self._data) def __repr__(self) -> str: - data = self._get_or_fetch() - return repr(data) - - @abstractmethod - def _create_instance(self, path: str, pathinfo: str, /, **kwargs: Any) -> T: - """Create an instance of 'T'. - - Returns - ------- - T - """ - raise NotImplementedError() - - def reload(self) -> Self: - """ - Clear the cache and reload the data from the API on the next access. - - Returns - ------- - ActiveSequence - The current instance with cleared cache, ready to reload data on next access. - """ - self._cache = None - return self + return repr(self._data) class ActiveFinderMethods(ActiveSequence[T], ABC): @@ -200,9 +201,7 @@ def find(self, uid) -> T: ------- T """ - if self._cache: - # Check if the record already exists in the cache. - # It is assumed that local cache scan is faster than an additional HTTP request. + if self.cached(): conditions = {self._uid: uid} result = self.find_by(**conditions) if result: @@ -211,13 +210,7 @@ def find(self, uid) -> T: endpoint = self._ctx.url + self._path + uid response = self._ctx.session.get(endpoint) result = response.json() - result = self._create_instance(self._path, uid, **result) - - # Invalidate the cache. - # It is assumed that the cache is stale since a record exists on the server and not in the cache. - self._cache = None - - return result + return self._to_instance(result) def find_by(self, **conditions: Any) -> Optional[T]: """ @@ -234,5 +227,4 @@ def find_by(self, **conditions: Any) -> Optional[T]: Optional[T] The first record matching the conditions, or `None` if no match is found. """ - data = self._get_or_fetch() - return next((v for v in data if v.items() >= conditions.items()), None) + return next((v for v in self._data if v.items() >= conditions.items()), None) From 03accf84423a70c53db7b41373ad5ebda7b85414 Mon Sep 17 00:00:00 2001 From: tdstein Date: Wed, 30 Oct 2024 13:54:53 -0400 Subject: [PATCH 28/31] removes pathinfo options when not needed --- src/posit/connect/content.py | 6 +++--- src/posit/connect/jobs.py | 14 +++++--------- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/src/posit/connect/content.py b/src/posit/connect/content.py index 055e60bc..5e7e9ae7 100644 --- a/src/posit/connect/content.py +++ b/src/posit/connect/content.py @@ -37,9 +37,9 @@ class ContentItemOwner(Resource): class ContentItem(JobsMixin, VanityMixin, Resource): def __init__(self, /, params: ResourceParameters, **kwargs): ctx = Context(params.session, params.url) - path = f"v1/content" - pathinfo = kwargs["guid"] - super().__init__(ctx, path, pathinfo, **kwargs) + uid = kwargs["guid"] + path = f"v1/content/{uid}" + super().__init__(ctx, path, **kwargs) def __getitem__(self, key: Any) -> Any: v = super().__getitem__(key) diff --git a/src/posit/connect/jobs.py b/src/posit/connect/jobs.py index 1ab87a70..73796635 100644 --- a/src/posit/connect/jobs.py +++ b/src/posit/connect/jobs.py @@ -127,7 +127,7 @@ def destroy(self) -> None: class Jobs(ActiveFinderMethods[Job], ActiveSequence[Job]): - def __init__(self, ctx: Context, path: str, pathinfo: str = "jobs", uid: str = "key"): + def __init__(self, ctx: Context, path: str): """A collection of jobs. Parameters @@ -135,13 +135,9 @@ def __init__(self, ctx: Context, path: str, pathinfo: str = "jobs", uid: str = " ctx : Context The context object containing the session and URL for API interactions path : str - The HTTP path component for the collection endpoint - pathinfo : str - The HTTP part of the path directed at a specific resource, by default "jobs" - uid : str, optional - The field name used to uniquely identify records, by default "guid" + The HTTP path component for the jobs endpoint (e.g., 'v1/content/544509fc-e4f0-41de-acb4-1fe3a2c1d797') """ - super().__init__(ctx, path, pathinfo, uid) + super().__init__(ctx, path, "jobs", "key") def _create_instance(self, path: str, pathinfo: str, /, **kwargs: Any) -> Job: """Creates a Job instance. @@ -291,7 +287,7 @@ def find_by(self, **conditions) -> Optional[Job]: class JobsMixin(Active, Resource): """Mixin class to add a jobs attribute to a resource.""" - def __init__(self, ctx, path, pathinfo="", /, **kwargs): + def __init__(self, ctx, path, /, **kwargs): """Mixin class which adds a `jobs` attribute to the Active Resource. Parameters @@ -305,5 +301,5 @@ def __init__(self, ctx, path, pathinfo="", /, **kwargs): **attributes : dict Resource attributes passed """ - super().__init__(ctx, path, pathinfo, **kwargs) + super().__init__(ctx, path, "", **kwargs) self.jobs = Jobs(ctx, self._path) From 77f8a38b75d77744f01f11d27e820a468ed2e75f Mon Sep 17 00:00:00 2001 From: tdstein Date: Wed, 30 Oct 2024 13:59:15 -0400 Subject: [PATCH 29/31] remove unecessary arg --- src/posit/connect/jobs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/posit/connect/jobs.py b/src/posit/connect/jobs.py index 73796635..96883800 100644 --- a/src/posit/connect/jobs.py +++ b/src/posit/connect/jobs.py @@ -301,5 +301,5 @@ def __init__(self, ctx, path, /, **kwargs): **attributes : dict Resource attributes passed """ - super().__init__(ctx, path, "", **kwargs) + super().__init__(ctx, path, **kwargs) self.jobs = Jobs(ctx, self._path) From ac488c731d4a456555ef2ed6102cfaa3ca7b4d84 Mon Sep 17 00:00:00 2001 From: tdstein Date: Wed, 30 Oct 2024 14:11:12 -0400 Subject: [PATCH 30/31] additional path cleanup --- src/posit/connect/jobs.py | 37 +++++++++++---------------- src/posit/connect/resources.py | 46 ++++++++++++++++++---------------- 2 files changed, 40 insertions(+), 43 deletions(-) diff --git a/src/posit/connect/jobs.py b/src/posit/connect/jobs.py index 96883800..2d116b50 100644 --- a/src/posit/connect/jobs.py +++ b/src/posit/connect/jobs.py @@ -1,3 +1,4 @@ +import posixpath from typing import Any, Literal, Optional, TypedDict, overload from typing_extensions import NotRequired, Required, Unpack @@ -100,14 +101,8 @@ class _Job(TypedDict): 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.""" - @overload - def __init__(self, ctx: Context, path: str, pathinfo: str, /, **attributes: Unpack[_Job]): ... - - @overload - def __init__(self, ctx: Context, path: str, pathinfo: str, /, **attributes: Any): ... - - def __init__(self, ctx: Context, path: str, pathinfo: str, /, **attributes: Any): - super().__init__(ctx, path, pathinfo, **attributes) + def __init__(self, ctx: Context, path: str, /, **attributes: Unpack[_Job]): + super().__init__(ctx, path, **attributes) def destroy(self) -> None: """Destroy the job. @@ -135,25 +130,23 @@ def __init__(self, ctx: Context, path: str): 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') + The HTTP path component for the jobs endpoint (e.g., 'v1/content/544509fc-e4f0-41de-acb4-1fe3a2c1d797/jobs') """ - super().__init__(ctx, path, "jobs", "key") + super().__init__(ctx, path, "key") - def _create_instance(self, path: str, pathinfo: str, /, **kwargs: Any) -> Job: + def _create_instance(self, path: str, /, **attributes: Any) -> Job: """Creates a Job instance. Parameters ---------- path : str - The HTTP path component for the collection endpoint - pathinfo : str - The HTTP part of the path directed at a specific resource + The HTTP path component for the Job resource endpoint (e.g., 'v1/content/544509fc-e4f0-41de-acb4-1fe3a2c1d797/jobs/7add0bc0-0d89-4397-ab51-90ad4bc3f5c9') Returns ------- Job """ - return Job(self._ctx, path, pathinfo, **kwargs) + return Job(self._ctx, path, **attributes) class _FindByRequest(TypedDict, total=False): # Identifiers @@ -287,19 +280,19 @@ def find_by(self, **conditions) -> Optional[Job]: class JobsMixin(Active, Resource): """Mixin class to add a jobs attribute to a resource.""" - def __init__(self, ctx, path, /, **kwargs): + 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. + The context object containing the session and URL for API interactions path : str - The HTTP path component for the collection endpoint - pathinfo : str - The HTTP part of the path directed at a specific resource + The HTTP path component for the resource endpoint **attributes : dict Resource attributes passed """ - super().__init__(ctx, path, **kwargs) - self.jobs = Jobs(ctx, self._path) + super().__init__(ctx, path, **attributes) + + path = posixpath.join(path, "jobs") + self.jobs = Jobs(ctx, path) diff --git a/src/posit/connect/resources.py b/src/posit/connect/resources.py index c3d2f282..bec15a8c 100644 --- a/src/posit/connect/resources.py +++ b/src/posit/connect/resources.py @@ -51,7 +51,7 @@ def __init__(self, params: ResourceParameters) -> None: class Active(ABC, Resource): - def __init__(self, ctx: Context, path: str, pathinfo: str = "", /, **attributes): + def __init__(self, ctx: Context, path: str, /, **attributes): """A dict abstraction for any HTTP endpoint that returns a singular resource. Extends the `Resource` class and provides additional functionality for via the session context and an optional parent resource. @@ -61,23 +61,14 @@ def __init__(self, ctx: Context, path: str, pathinfo: str = "", /, **attributes) ctx : Context The context object containing the session and URL for API interactions. path : str - The HTTP path component for the collection endpoint - pathinfo : str - The HTTP part of the path directed at a specific resource + The HTTP path component for the resource endpoint **attributes : dict Resource attributes passed - - Attributes - ---------- - _ctx : Context - The context object containing the session and URL for API interactions - _path : str - The HTTP path for the collection endpoint. """ params = ResourceParameters(ctx.session, ctx.url) super().__init__(params, **attributes) self._ctx = ctx - self._path = posixpath.join(path, pathinfo) + self._path = path T = TypeVar("T", bound="Active") @@ -85,18 +76,30 @@ def __init__(self, ctx: Context, path: str, pathinfo: str = "", /, **attributes) class ActiveSequence(ABC, Generic[T], Sequence[T]): - """A sequence for any HTTP endpoint that returns a collection.""" + """A sequence for any HTTP GET endpoint that returns a collection.""" + + _cache: Optional[List[T]] - def __init__(self, ctx: Context, path: str, pathinfo: str = "", uid: str = "guid"): - """A sequence abstraction for any HTTP GET endpoint that returns a collection.""" + 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: Context = ctx - self._path: str = posixpath.join(path, pathinfo) - self._uid: str = uid - self._cache: Optional[List[T]] = None + self._ctx = ctx + self._path = path + self._uid = uid + self._cache = None @abstractmethod - def _create_instance(self, path: str, pathinfo: str, /, **kwargs: Any) -> T: + def _create_instance(self, path: str, /, **kwargs: Any) -> T: """Create an instance of 'T'.""" raise NotImplementedError() @@ -140,7 +143,8 @@ def _fetch(self) -> List[T]: def _to_instance(self, result: dict) -> T: """Converts a result into an instance of T.""" uid = result[self._uid] - return self._create_instance(self._path, uid, **result) + path = posixpath.join(self._path, uid) + return self._create_instance(path, **result) @property def _data(self) -> List[T]: From b3ff1cd4cdeb419d32081540716c7febc6033faa Mon Sep 17 00:00:00 2001 From: tdstein Date: Thu, 31 Oct 2024 11:35:38 -0400 Subject: [PATCH 31/31] remove self imposed complexity of the method --- src/posit/connect/resources.py | 21 +-------------------- tests/posit/connect/test_jobs.py | 19 ------------------- 2 files changed, 1 insertion(+), 39 deletions(-) diff --git a/src/posit/connect/resources.py b/src/posit/connect/resources.py index bec15a8c..d7e31428 100644 --- a/src/posit/connect/resources.py +++ b/src/posit/connect/resources.py @@ -103,19 +103,6 @@ def _create_instance(self, path: str, /, **kwargs: Any) -> T: """Create an instance of 'T'.""" raise NotImplementedError() - def cached(self) -> bool: - """Returns True if the collection is cached. - - Returns - ------- - bool - - See Also - -------- - reload - """ - return self._cache is not None - def reload(self) -> Self: """Reloads the collection from Connect. @@ -194,7 +181,7 @@ def find(self, uid) -> T: """ Find a record by its unique identifier. - If the cache is already populated, it is checked first for matching record. If not, a conventional GET request is made to the Connect server. + Fetches the record from Connect by it's identifier. Parameters ---------- @@ -205,12 +192,6 @@ def find(self, uid) -> T: ------- T """ - if self.cached(): - conditions = {self._uid: uid} - result = self.find_by(**conditions) - if result: - return result - endpoint = self._ctx.url + self._path + uid response = self._ctx.session.get(endpoint) result = response.json() diff --git a/tests/posit/connect/test_jobs.py b/tests/posit/connect/test_jobs.py index a57216e0..91cc2555 100644 --- a/tests/posit/connect/test_jobs.py +++ b/tests/posit/connect/test_jobs.py @@ -46,25 +46,6 @@ def test(self): job = content.jobs.find("tHawGvHZTosJA2Dx") assert job["key"] == "tHawGvHZTosJA2Dx" - @responses.activate - def test_cached(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/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 content.jobs - job = content.jobs.find("tHawGvHZTosJA2Dx") - assert job["key"] == "tHawGvHZTosJA2Dx" - @responses.activate def test_miss(self): responses.get(