From bc2cfcbb928f3d1dffb50c71554c615b5ce8f50e Mon Sep 17 00:00:00 2001 From: tdstein Date: Fri, 18 Oct 2024 09:07:41 -0400 Subject: [PATCH 1/9] 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 2/9] --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 3/9] 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 4/9] 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 5/9] 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 6/9] 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 7/9] 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 8/9] 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 9/9] 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)