diff --git a/integration/tests/posit/connect/test_jobs.py b/integration/tests/posit/connect/test_jobs.py new file mode 100644 index 00000000..9617cf7e --- /dev/null +++ b/integration/tests/posit/connect/test_jobs.py @@ -0,0 +1,38 @@ +from pathlib import Path + +import pytest +from packaging import version + +from posit import connect + +from . import CONNECT_VERSION + + +class TestJobs: + @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 + + @pytest.mark.skipif( + CONNECT_VERSION <= version.parse("2023.01.1"), + reason="Quarto not available", + ) + 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/content.py b/src/posit/connect/content.py index bd0f4ede..27142d25 100644 --- a/src/posit/connect/content.py +++ b/src/posit/connect/content.py @@ -9,7 +9,9 @@ from . import tasks from .bundles import Bundles +from .context import Context 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 +34,11 @@ class ContentItemOwner(Resource): pass -class ContentItem(VanityMixin, 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 c93fe7b0..f8ef13b2 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 @@ -38,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 new file mode 100644 index 00000000..acaf0765 --- /dev/null +++ b/src/posit/connect/jobs.py @@ -0,0 +1,292 @@ +from typing import Literal, Optional, TypedDict, overload + +from typing_extensions import NotRequired, Required, Unpack + +from .resources import Active, ActiveFinderMethods, ActiveSequence, Resource + +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(Active): + class _Job(TypedDict): + # Identifiers + id: Required[str] + """A unique identifier for the job.""" + + ppid: Required[Optional[str]] + """Identifier of the parent process.""" + + pid: Required[str] + """Identifier of the process running the job.""" + + key: Required[str] + """A unique key to identify this job.""" + + remote_id: Required[Optional[str]] + """Identifier for off-host execution configurations.""" + + app_id: Required[str] + """Identifier of the parent content associated with the job.""" + + variant_id: Required[str] + """Identifier of the variant responsible for the job.""" + + bundle_id: Required[str] + """Identifier of the content bundle linked to the job.""" + + # Timestamps + start_time: Required[str] + """RFC3339 timestamp indicating when the job started.""" + + end_time: Required[Optional[str]] + """RFC3339 timestamp indicating when the job finished.""" + + last_heartbeat_time: Required[str] + """RFC3339 timestamp of the last recorded activity for the job.""" + + queued_time: Required[Optional[str]] + """RFC3339 timestamp when the job was added to the queue.""" + + # Status and Exit Information + status: Required[Literal[0, 1, 2]] + """Current status. Options are 0 (Active), 1 (Finished), and 2 (Finalized)""" + + exit_code: Required[Optional[int]] + """The job's exit code, available after completion.""" + + # Environment Information + hostname: Required[str] + """Name of the node processing the job.""" + + cluster: Required[Optional[str]] + """Location where the job runs, either 'Local' or the cluster name.""" + + image: Required[Optional[str]] + """Location of the content in clustered environments.""" + + run_as: Required[str] + """UNIX user responsible for executing the job.""" + + # Queue and Scheduling Information + queue_name: Required[Optional[str]] + """Name of the queue processing the job, relevant for scheduled reports.""" + + # Job Metadata + tag: Required[JobTag] + """A tag categorizing the job type. Options are build_jupyter, build_report, build_site, configure_report, git, packrat_restore, python_restore, render_shiny, run_api, run_app, run_bokeh_app, run_dash_app, run_fastapi_app, run_pyshiny_app, run_python_api, run_streamlit, run_tensorflow, run_voila_app, testing, unknown, val_py_ext_pkg, val_r_ext_pkg, and val_r_install.""" + + def __init__(self, ctx, 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._parent['guid']}/jobs/{self['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._ctx.session.delete(self._endpoint) + + +class Jobs( + ActiveFinderMethods[Job], + ActiveSequence[Job], +): + def __init__(self, ctx, parent: Active, 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 + 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: Required[str] + """A unique identifier for the job.""" + + ppid: NotRequired[Optional[str]] + """Identifier of the parent process.""" + + pid: NotRequired[str] + """Identifier of the process running the job.""" + + key: NotRequired[str] + """A unique key to identify this job.""" + + remote_id: NotRequired[Optional[str]] + """Identifier for off-host execution configurations.""" + + app_id: NotRequired[str] + """Identifier of the parent content associated with the job.""" + + variant_id: NotRequired[str] + """Identifier of the variant responsible for the job.""" + + bundle_id: NotRequired[str] + """Identifier of the content bundle linked to the job.""" + + # 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) -> Optional[Job]: + return super().find_by(**conditions) + + +class JobsMixin(Active, Resource): + """Mixin class to add a jobs attribute to a resource.""" + + def __init__(self, ctx, **kwargs): + super().__init__(ctx, **kwargs) + self.jobs = Jobs(ctx, self) diff --git a/src/posit/connect/resources.py b/src/posit/connect/resources.py index be1ef7b7..3d652281 100644 --- a/src/posit/connect/resources.py +++ b/src/posit/connect/resources.py @@ -1,8 +1,12 @@ import warnings +from abc import ABC, abstractmethod from dataclasses import dataclass +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 @@ -43,3 +47,196 @@ def update(self, *args, **kwargs): class Resources: def __init__(self, params: ResourceParameters) -> None: self.params = params + + +class Active(ABC, Resource): + def __init__(self, ctx: Context, parent: Optional["Active"] = None, **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. + + 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. + """ + params = ResourceParameters(ctx.session, ctx.url) + super().__init__(params, **kwargs) + self._ctx = ctx + self._parent = parent + + +T = TypeVar("T", bound="Active") +"""A type variable that is bound to the `Active` class""" + + +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. + + 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 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._ctx = ctx + self._parent = parent + 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]: + """ + 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` using the class specified by `_cls`. + The results are cached after the first request and reused for subsequent access unless reloaded. + + Returns + ------- + 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._create_instance(**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): + return self._data[index] + + def __len__(self) -> int: + return len(self._data) + + def __str__(self) -> str: + return str(self._data) + + def __repr__(self) -> str: + return repr(self._data) + + @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. + + Returns + ------- + ActiveSequence + The current instance with cleared cache, ready to reload data on next access. + """ + self._cache = None + 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 + + 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. + + Parameters + ---------- + uid : Any + The unique identifier of the record. + + Returns + ------- + 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) + 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}'") + + return result + + def find_by(self, **conditions: Any) -> Optional[T]: + """ + Find the first record matching the specified conditions. + + There is no implied ordering, so if order matters, you should specify it yourself. + + Parameters + ---------- + **conditions : Any + + Returns + ------- + Optional[T] + The first record matching the conditions, or `None` if no match is found. + """ + 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/__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_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 diff --git a/tests/posit/connect/test_jobs.py b/tests/posit/connect/test_jobs.py new file mode 100644 index 00000000..e74d6081 --- /dev/null +++ b/tests/posit/connect/test_jobs.py @@ -0,0 +1,157 @@ +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()