diff --git a/.gitignore b/.gitignore index c5b32315..31c32c48 100644 --- a/.gitignore +++ b/.gitignore @@ -169,3 +169,4 @@ cython_debug/ .ruff_cache/ /.luarc.json +_dev/ diff --git a/Makefile b/Makefile index 7a1e46ee..4365b596 100644 --- a/Makefile +++ b/Makefile @@ -65,8 +65,8 @@ it: $(UV_LOCK) $(MAKE) -C ./integration lint: dev - $(UV) run pyright $(UV) run ruff check + $(UV) run pyright test: dev $(UV) run coverage run --source=src -m pytest tests diff --git a/integration/tests/posit/connect/__init__.py b/integration/tests/posit/connect/__init__.py index 7d8dd0b3..cb5b6e72 100644 --- a/integration/tests/posit/connect/__init__.py +++ b/integration/tests/posit/connect/__init__.py @@ -3,4 +3,6 @@ from posit import connect client = connect.Client() -CONNECT_VERSION = version.parse(client.version) +client_version = client.version +assert client_version is not None +CONNECT_VERSION = version.parse(client_version) diff --git a/integration/tests/posit/connect/test_content.py b/integration/tests/posit/connect/test_content.py index fd0bbbf5..4b555824 100644 --- a/integration/tests/posit/connect/test_content.py +++ b/integration/tests/posit/connect/test_content.py @@ -29,7 +29,7 @@ def test_find(self): assert self.client.content.find() def test_find_by(self): - assert self.client.content.find_by(guid=self.content["guid"]) == self.content + assert self.client.content.find_by(name=self.content["name"]) == self.content def test_find_one(self): assert self.client.content.find_one() diff --git a/integration/tests/posit/connect/test_content_item_repository.py b/integration/tests/posit/connect/test_content_item_repository.py new file mode 100644 index 00000000..6260b6e3 --- /dev/null +++ b/integration/tests/posit/connect/test_content_item_repository.py @@ -0,0 +1,88 @@ +import pytest +from packaging import version + +from posit import connect +from posit.connect.content import ContentItem, ContentItemRepository + +from . import CONNECT_VERSION + + +class TestContentItemRepository: + content: ContentItem + + @classmethod + def setup_class(cls): + cls.client = connect.Client() + cls.content = cls.client.content.create(name="example") + + @classmethod + def teardown_class(cls): + cls.content.delete() + assert cls.client.content.count() == 0 + + @property + def repo_repository(self): + return "https://github.com/posit-dev/posit-sdk-py" + + @property + def repo_branch(self): + return "1dacc4dd" + + @property + def repo_directory(self): + return "integration/resources/connect/bundles/example-quarto-minimal" + + @property + def repo_polling(self): + return False + + @property + def default_repository(self): + return { + "repository": self.repo_repository, + "branch": self.repo_branch, + "directory": self.repo_directory, + "polling": self.repo_polling, + } + + @pytest.mark.skipif( + # Added to the v2022.12.0 milestone + # https://github.com/rstudio/connect/issues/22242#event-7859377097 + CONNECT_VERSION < version.parse("2022.12.0"), + reason="Repository routes not implemented", + ) + def test_create_get_update_delete(self): + content = self.content + + # None by default + assert content.repository is None + + # Create + new_repo = content.create_repository(**self.default_repository) + + # Get + content_repo = content.repository + assert content_repo is not None + + def assert_repo(r: ContentItemRepository): + assert isinstance(content_repo, ContentItemRepository) + assert r["repository"] == self.repo_repository + assert r["branch"] == self.repo_branch + assert r["directory"] == self.repo_directory + assert r["polling"] is self.repo_polling + + assert_repo(new_repo) + assert_repo(content_repo) + + # Update + ex_branch = "main" + updated_repo = content_repo.update(branch=ex_branch) + assert updated_repo["branch"] == ex_branch + + assert updated_repo["repository"] == self.repo_repository + assert updated_repo["directory"] == self.repo_directory + assert updated_repo["polling"] is self.repo_polling + + # Delete + content_repo.destroy() + assert content.repository is None diff --git a/pyproject.toml b/pyproject.toml index 0cf48c77..79985b04 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,7 @@ Source = "https://github.com/posit-dev/posit-sdk-py" Issues = "https://github.com/posit-dev/posit-sdk-py/issues" [tool.pyright] -include = ["src"] +include = ["src", "tests", "integration/tests"] [tool.pytest.ini_options] testpaths = ["tests"] @@ -70,6 +70,7 @@ extend-select = [ # Check docstring formatting. Many of these rules are intentionally ignored below. "D", + "ARG", # ARG; flake8-argparse: https://docs.astral.sh/ruff/rules/#flake8-unused-arguments-arg "E", # E; pycodestyle: https://docs.astral.sh/ruff/rules/#pycodestyle-e-w "F", # F; Pyflakes: https://docs.astral.sh/ruff/rules/#pyflakes-f "I", # I; isort: https://docs.astral.sh/ruff/rules/#isort-i @@ -111,8 +112,8 @@ ignore = [ ] [tool.ruff.lint.per-file-ignores] -"examples/*" = ["D"] -"tests/*" = ["D"] +"examples/connect*" = ["D", "ARG"] +"tests/posit/connect/*" = ["D", "ARG"] [tool.ruff.lint.pydocstyle] @@ -130,7 +131,7 @@ exclude = ".*" [dependency-groups] build = ["build"] coverage = ["coverage"] -examples = ["rsconnect-python", "pandas"] +examples = ["rsconnect-python", "pandas", "databricks", "shiny"] git = ["pre-commit"] lint = ["ruff", "pyright"] test = ["rsconnect-python", "responses", "pytest", "pyjson5"] diff --git a/src/posit/connect/_api.py b/src/posit/connect/_api.py new file mode 100644 index 00000000..b602c7b0 --- /dev/null +++ b/src/posit/connect/_api.py @@ -0,0 +1,278 @@ +# TODO-barret-future; Piecemeal migrate everything to leverage `ApiDictEndpoint` and `ApiListEndpoint` classes. +# TODO-barret-future; Merge any trailing behavior of `Active` or `ActiveList` into the new classes. + +from __future__ import annotations + +import itertools +import posixpath +from abc import ABC, abstractmethod +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, Generator, Generic, Optional, TypeVar, cast, overload + +from ._api_call import ApiCallMixin, get_api +from ._json import Jsonifiable, JsonifiableDict, ResponseAttrs + +if TYPE_CHECKING: + from .context import Context + + +# Design Notes: +# * Perform API calls on property retrieval. e.g. `my_content.repository` +# * Dictionary endpoints: Retrieve all attributes during init unless provided +# * List endpoints: Do not retrieve until `.fetch()` is called directly. Avoids cache invalidation issues. +# * While slower, all ApiListEndpoint helper methods should `.fetch()` on demand. +# * Only expose methods needed for `ReadOnlyDict`. +# * Ex: When inheriting from `dict`, we'd need to shut down `update`, `pop`, etc. +# * Use `ApiContextProtocol` to ensure that the class has the necessary attributes for API calls. +# * Inherit from `ApiCallMixin` to add all helper methods for API calls. +# * Classes should write the `path` only once within its init method. +# * Through regular interactions, the path should only be written once. + +# When making a new class, +# * Use a class to define the parameters and their types +# * If attaching the type info class to the parent class, start with `_`. E.g.: `ContentItemRepository._Attrs` +# * Document all attributes like normal +# * When the time comes that there are multiple attribute types, we can use overloads with full documentation and unpacking of type info class for each overload method. +# * Inherit from `ApiDictEndpoint` or `ApiListEndpoint` as needed +# * Init signature should be `def __init__(self, ctx: Context, path: str, /, **attrs: Jsonifiable) -> None:` + + +# This class should not have typing about the class keys as that would fix the class's typing. If +# for some reason, we _know_ the keys are fixed (as we've moved on to a higher version), we can add +# `Generic[AttrsT]` to the class. +class ReadOnlyDict(Mapping): + _attrs: ResponseAttrs + """Resource attributes passed.""" + + def __init__(self, attrs: ResponseAttrs) -> None: + """ + A read-only dict abstraction for any HTTP endpoint that returns a singular resource. + + Parameters + ---------- + attrs : dict + Resource attributes passed + """ + super().__init__() + self._attrs = attrs + + def get(self, key: str, default: Any = None) -> Any: + return self._attrs.get(key, default) + + def __getitem__(self, key: str) -> Any: + return self._attrs[key] + + def __setitem__(self, key: str, value: Any) -> None: + raise NotImplementedError( + "Resource attributes are locked. " + "To retrieve updated values, please retrieve the parent object again." + ) + + def __len__(self) -> int: + return self._attrs.__len__() + + def __iter__(self): + return self._attrs.__iter__() + + def __contains__(self, key: object) -> bool: + return self._attrs.__contains__(key) + + def __repr__(self) -> str: + return repr(self._attrs) + + def __str__(self) -> str: + return str(self._attrs) + + def keys(self): + return self._attrs.keys() + + def values(self): + return self._attrs.values() + + def items(self): + return self._attrs.items() + + +class ApiDictEndpoint(ApiCallMixin, ReadOnlyDict): + _ctx: Context + """The context object containing the session and URL for API interactions.""" + _path: str + """The HTTP path component for the resource endpoint.""" + + def _get_api(self, *path) -> JsonifiableDict | None: + super()._get_api(*path) + + def __init__( + self, + ctx: Context, + path: str, + get_data: Optional[bool] = None, + /, + **attrs: Jsonifiable, + ) -> None: + """ + A dict abstraction for any HTTP endpoint that returns a singular resource. + + Adds helper methods to interact with the API with reduced boilerplate. + + Parameters + ---------- + ctx : Context + The context object containing the session and URL for API interactions. + path : str + The HTTP path component for the resource endpoint + get_data : Optional[bool] + If `True`, fetch the API and set the attributes from the response. If `False`, only set + the provided attributes. If `None` [default], fetch the API if no attributes are + provided. + attrs : dict + Resource attributes passed + """ + # If no attributes are provided, fetch the API and set the attributes from the response + if get_data is None: + get_data = len(attrs) == 0 + + # If we should get data, fetch the API and set the attributes from the response + if get_data: + init_attrs: Jsonifiable = get_api(ctx, path) + init_attrs_dict = cast(ResponseAttrs, init_attrs) + # Overwrite the initial attributes with `attrs`: e.g. {'key': value} | {'content_guid': '123'} + init_attrs_dict.update(attrs) + attrs = init_attrs_dict + + super().__init__(attrs) + self._ctx = ctx + self._path = path + + +T = TypeVar("T", bound="ReadOnlyDict") +"""A type variable that is bound to the `Active` class""" + + +class ApiListEndpoint(ApiCallMixin, Generic[T], ABC, object): + """A HTTP GET endpoint that can fetch a collection.""" + + def __init__(self, *, ctx: Context, path: str, uid_key: str = "guid") -> None: + """A sequence abstraction for any HTTP GET endpoint that returns a collection. + + Parameters + ---------- + ctx : Context + The context object containing the session and URL for API interactions. + path : str + The HTTP path component for the collection endpoint + uid_key : str, optional + The field name of that uniquely identifiers an instance of T, by default "guid" + """ + super().__init__() + self._ctx = ctx + self._path = path + self._uid_key = uid_key + + @abstractmethod + def _create_instance(self, path: str, /, **kwargs: Any) -> T: + """Create an instance of 'T'.""" + raise NotImplementedError() + + def fetch(self) -> Generator[T, None, None]: + """Fetch the collection. + + Fetches the collection directly from Connect. This operation does not effect the cache state. + + Returns + ------- + List[T] + """ + results: Jsonifiable = self._get_api() + results_list = cast(list[JsonifiableDict], results) + for result in results_list: + yield self._to_instance(result) + + def __iter__(self) -> Generator[T, None, None]: + return self.fetch() + + def _to_instance(self, result: dict) -> T: + """Converts a result into an instance of T.""" + uid = result[self._uid_key] + path = posixpath.join(self._path, uid) + return self._create_instance(path, **result) + + @overload + def __getitem__(self, index: int) -> T: ... + + @overload + def __getitem__(self, index: slice) -> Generator[T, None, None]: ... + + def __getitem__(self, index: int | slice) -> T | Generator[T, None, None]: + if isinstance(index, slice): + results = itertools.islice(self.fetch(), index.start, index.stop, index.step) + for result in results: + yield result + else: + return list(itertools.islice(self.fetch(), index, index + 1))[0] + + # def __len__(self) -> int: + # return len(self.fetch()) + + def __str__(self) -> str: + return self.__repr__() + + def __repr__(self) -> str: + # Jobs - 123 items + return repr( + f"{self.__class__.__name__} - { len(list(self.fetch())) } items - {self._path}" + ) + + def find(self, uid: str) -> T | None: + """ + Find a record by its unique identifier. + + Fetches the record from Connect by it's identifier. + + Parameters + ---------- + uid : str + The unique identifier of the record. + + Returns + ------- + : + Single instance of T if found, else None + """ + result: Jsonifiable = self._get_api(uid) + result_obj = cast(JsonifiableDict, result) + + return self._to_instance(result_obj) + + def find_by(self, **conditions: Any) -> T | None: + """ + Find the first record matching the specified conditions. + + There is no implied ordering, so if order matters, you should specify it yourself. + + Parameters + ---------- + **conditions : Any + + Returns + ------- + T + The first record matching the conditions, or `None` if no match is found. + """ + results = self.fetch() + + conditions_items = conditions.items() + + # Get the first item of the generator that matches the conditions + # If no item is found, return None + return next( + ( + # Return result + result + # Iterate through `results` generator + for result in results + # If all `conditions`'s key/values are found in `result`'s key/values... + if result.items() >= conditions_items + ), + None, + ) diff --git a/src/posit/connect/_api_call.py b/src/posit/connect/_api_call.py new file mode 100644 index 00000000..f90244aa --- /dev/null +++ b/src/posit/connect/_api_call.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import posixpath +from typing import TYPE_CHECKING, Protocol + +if TYPE_CHECKING: + from ._json import Jsonifiable + from .context import Context + + +class ApiCallProtocol(Protocol): + _ctx: Context + _path: str + + def _endpoint(self, *path) -> str: ... + def _get_api(self, *path) -> Jsonifiable: ... + def _delete_api(self, *path) -> Jsonifiable | None: ... + def _patch_api(self, *path, json: Jsonifiable | None) -> Jsonifiable: ... + def _put_api(self, *path, json: Jsonifiable | None) -> Jsonifiable: ... + + +def endpoint(ctx: Context, *path) -> str: + return ctx.url + posixpath.join(*path) + + +# Helper methods for API interactions +def get_api(ctx: Context, *path) -> Jsonifiable: + response = ctx.session.get(endpoint(ctx, *path)) + return response.json() + + +def put_api( + ctx: Context, + *path, + json: Jsonifiable | None, +) -> Jsonifiable: + response = ctx.session.put(endpoint(ctx, *path), json=json) + return response.json() + + +# Mixin class for API interactions + + +class ApiCallMixin: + def _endpoint(self: ApiCallProtocol, *path) -> str: + return endpoint(self._ctx, self._path, *path) + + def _get_api(self: ApiCallProtocol, *path) -> Jsonifiable: + response = self._ctx.session.get(self._endpoint(*path)) + return response.json() + + def _delete_api(self: ApiCallProtocol, *path) -> Jsonifiable | None: + response = self._ctx.session.delete(self._endpoint(*path)) + if len(response.content) == 0: + return None + return response.json() + + def _patch_api( + self: ApiCallProtocol, + *path, + json: Jsonifiable | None, + ) -> Jsonifiable: + response = self._ctx.session.patch(self._endpoint(*path), json=json) + return response.json() + + def _put_api( + self: ApiCallProtocol, + *path, + json: Jsonifiable | None, + ) -> Jsonifiable: + response = self._ctx.session.put(self._endpoint(*path), json=json) + return response.json() diff --git a/src/posit/connect/_json.py b/src/posit/connect/_json.py new file mode 100644 index 00000000..62f28f82 --- /dev/null +++ b/src/posit/connect/_json.py @@ -0,0 +1,37 @@ +from typing import Dict, List, Tuple, TypeVar, Union + +# Implemented in https://github.com/posit-dev/py-shiny/blob/415ced034e6c500adda524abb7579731c32088b5/shiny/types.py#L357-L386 +# Table from: https://github.com/python/cpython/blob/df1eec3dae3b1eddff819fd70f58b03b3fbd0eda/Lib/json/encoder.py#L77-L95 +# +-------------------+---------------+ +# | Python | JSON | +# +===================+===============+ +# | dict | object | +# +-------------------+---------------+ +# | list, tuple | array | +# +-------------------+---------------+ +# | str | string | +# +-------------------+---------------+ +# | int, float | number | +# +-------------------+---------------+ +# | True | true | +# +-------------------+---------------+ +# | False | false | +# +-------------------+---------------+ +# | None | null | +# +-------------------+---------------+ +Jsonifiable = Union[ + str, + int, + float, + bool, + None, + List["Jsonifiable"], + Tuple["Jsonifiable", ...], + "JsonifiableDict", +] + +JsonifiableT = TypeVar("JsonifiableT", bound="Jsonifiable") +JsonifiableDict = Dict[str, Jsonifiable] +JsonifiableList = List[JsonifiableT] + +ResponseAttrs = Dict[str, Jsonifiable] diff --git a/src/posit/connect/_typing_extensions.py b/src/posit/connect/_typing_extensions.py new file mode 100644 index 00000000..951bb118 --- /dev/null +++ b/src/posit/connect/_typing_extensions.py @@ -0,0 +1,42 @@ +# # Within file flags to ignore unused imports +# flake8: noqa: F401 +# pyright: reportUnusedImport=false + +# Extended from https://github.com/posit-dev/py-shiny/blob/main/shiny/_typing_extensions.py + +__all__ = ( + "Required", + "NotRequired", + "Self", + "TypedDict", + "Unpack", +) + + +import sys + +if sys.version_info >= (3, 10): + from typing import TypeGuard +else: + from typing_extensions import TypeGuard + +# Even though TypedDict is available in Python 3.8, because it's used with NotRequired, +# they should both come from the same typing module. +# https://peps.python.org/pep-0655/#usage-in-python-3-11 +if sys.version_info >= (3, 11): + from typing import NotRequired, Required, Self, TypedDict, Unpack +else: + from typing_extensions import ( + NotRequired, + Required, + Self, + TypedDict, + Unpack, + ) + + +# The only purpose of the following line is so that pyright will put all of the +# conditional imports into the .pyi file when generating type stubs. Without this line, +# pyright will not include the above imports in the generated .pyi file, and it will +# result in a lot of red squiggles in user code. +_: "TypeGuard | NotRequired | Required | TypedDict | Self | Unpack" # type:ignore diff --git a/src/posit/connect/_utils.py b/src/posit/connect/_utils.py new file mode 100644 index 00000000..26842bbc --- /dev/null +++ b/src/posit/connect/_utils.py @@ -0,0 +1,7 @@ +from __future__ import annotations + +from typing import Any + + +def drop_none(x: dict[str, Any]) -> dict[str, Any]: + return {k: v for k, v in x.items() if v is not None} diff --git a/src/posit/connect/client.py b/src/posit/connect/client.py index 85397c60..5ef02c4d 100644 --- a/src/posit/connect/client.py +++ b/src/posit/connect/client.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Optional, overload +from typing import overload from requests import Response, Session @@ -158,7 +158,7 @@ def __init__(self, *args, **kwargs) -> None: self.ctx = Context(self.session, self.cfg.url) @property - def version(self) -> Optional[str]: + def version(self) -> str | None: """ The server version. diff --git a/src/posit/connect/content.py b/src/posit/connect/content.py index 9dc51fad..0ff2db75 100644 --- a/src/posit/connect/content.py +++ b/src/posit/connect/content.py @@ -5,12 +5,23 @@ import posixpath import time from posixpath import dirname -from typing import TYPE_CHECKING, Any, List, Literal, Optional, overload +from typing import ( + TYPE_CHECKING, + Any, + List, + Literal, + Optional, + cast, + overload, +) from . import tasks +from ._api import ApiDictEndpoint, JsonifiableDict +from ._typing_extensions import NotRequired, Required, TypedDict, Unpack from .bundles import Bundles from .context import Context from .env import EnvVars +from .errors import ClientError from .jobs import JobsMixin from .oauth.associations import ContentItemAssociations from .permissions import Permissions @@ -22,14 +33,138 @@ from .tasks import Task +def _assert_guid(guid: str): + assert isinstance(guid, str), "Expected 'guid' to be a string" + assert len(guid) > 0, "Expected 'guid' to be non-empty" + + +def _assert_content_guid(content_guid: str): + assert isinstance(content_guid, str), "Expected 'content_guid' to be a string" + assert len(content_guid) > 0, "Expected 'content_guid' to be non-empty" + + +class ContentItemRepository(ApiDictEndpoint): + """ + Content items GitHub repository information. + + See Also + -------- + * Get info: https://docs.posit.co/connect/api/#get-/v1/content/-guid-/repository + * Delete info: https://docs.posit.co/connect/api/#delete-/v1/content/-guid-/repository + * Update info: https://docs.posit.co/connect/api/#patch-/v1/content/-guid-/repository + """ + + class _Attrs(TypedDict, total=False): + repository: str + """URL for the repository.""" + branch: NotRequired[str] + """The tracked Git branch.""" + directory: NotRequired[str] + """Directory containing the content.""" + polling: NotRequired[bool] + """Indicates that the Git repository is regularly polled.""" + + def __init__( + self, + ctx: Context, + /, + *, + content_guid: str, + # By default, the `attrs` will be retrieved from the API if no `attrs` are supplied. + **attrs: Unpack[ContentItemRepository._Attrs], + ) -> None: + """Content items GitHub repository information. + + Parameters + ---------- + ctx : Context + The context object containing the session and URL for API interactions. + content_guid : str + The unique identifier of the content item. + **attrs : ContentItemRepository._Attrs + Attributes for the content item repository. If not supplied, the attributes will be + retrieved from the API upon initialization + """ + _assert_content_guid(content_guid) + + path = self._api_path(content_guid) + # Only fetch data if `attrs` are not supplied + get_data = len(attrs) == 0 + super().__init__(ctx, path, get_data, **{"content_guid": content_guid, **attrs}) + + @classmethod + def _api_path(cls, content_guid: str) -> str: + return f"v1/content/{content_guid}/repository" + + @classmethod + def _create( + cls, + ctx: Context, + content_guid: str, + **attrs: Unpack[ContentItemRepository._Attrs], + ) -> ContentItemRepository: + from ._api_call import put_api + + result = put_api(ctx, cls._api_path(content_guid), json=cast(JsonifiableDict, attrs)) + + return ContentItemRepository( + ctx, + content_guid=content_guid, + **result, # pyright: ignore[reportCallIssue] + ) + + def destroy(self) -> None: + """ + Delete the content's git repository location. + + See Also + -------- + * https://docs.posit.co/connect/api/#delete-/v1/content/-guid-/repository + """ + self._delete_api() + + def update( + self, + # *, + **attrs: Unpack[ContentItemRepository._Attrs], + ) -> ContentItemRepository: + """Update the content's repository. + + Parameters + ---------- + repository: str, optional + URL for the repository. Default is None. + branch: str, optional + The tracked Git branch. Default is 'main'. + directory: str, optional + Directory containing the content. Default is '.' + polling: bool, optional + Indicates that the Git repository is regularly polled. Default is False. + + Returns + ------- + None + + See Also + -------- + * https://docs.posit.co/connect/api/#patch-/v1/content/-guid-/repository + """ + result = self._patch_api(json=cast(JsonifiableDict, dict(attrs))) + return ContentItemRepository( + self._ctx, + content_guid=self["content_guid"], + **result, # pyright: ignore[reportCallIssue] + ) + + class ContentItemOAuth(Resource): def __init__(self, params: ResourceParameters, content_guid: str) -> None: super().__init__(params) - self.content_guid = content_guid + self["content_guid"] = content_guid @property def associations(self) -> ContentItemAssociations: - return ContentItemAssociations(self.params, content_guid=self.content_guid) + return ContentItemAssociations(self.params, content_guid=self["content_guid"]) class ContentItemOwner(Resource): @@ -37,11 +172,79 @@ class ContentItemOwner(Resource): class ContentItem(JobsMixin, VanityMixin, Resource): - def __init__(self, /, params: ResourceParameters, **kwargs): + class _AttrsBase(TypedDict, total=False): + # # `name` will be set by other _Attrs classes + # name: str + + # Content Metadata + title: NotRequired[str] + description: NotRequired[str] + access_type: NotRequired[Literal["all", "acl", "logged_in"]] + # Timeout Settings + connection_timeout: NotRequired[int] + read_timeout: NotRequired[int] + init_timeout: NotRequired[int] + idle_timeout: NotRequired[int] + # Process and Resource Limits + max_processes: NotRequired[int] + min_processes: NotRequired[int] + max_conns_per_process: NotRequired[int] + load_factor: NotRequired[float] + cpu_request: NotRequired[float] + cpu_limit: NotRequired[float] + memory_request: NotRequired[int] + memory_limit: NotRequired[int] + amd_gpu_limit: NotRequired[int] + nvidia_gpu_limit: NotRequired[int] + # Execution Settings + run_as: NotRequired[str] + run_as_current_user: NotRequired[bool] + default_image_name: NotRequired[str] + default_r_environment_management: NotRequired[bool] + default_py_environment_management: NotRequired[bool] + service_account_name: NotRequired[str] + + class _AttrsNotRequired(_AttrsBase): + name: NotRequired[str] + owner_guid: NotRequired[str] + + class _Attrs(_AttrsBase): + name: Required[str] + owner_guid: NotRequired[str] + + class _AttrsCreate(_AttrsBase): + name: NotRequired[str] + # owner_guid is not supported + + @overload + def __init__( + self, + /, + params: ResourceParameters, + guid: str, + ) -> None: ... + + @overload + def __init__( + self, + /, + params: ResourceParameters, + guid: str, + **kwargs: Unpack[ContentItem._Attrs], + ) -> None: ... + + def __init__( + self, + /, + params: ResourceParameters, + guid: str, + **kwargs: Unpack[ContentItem._AttrsNotRequired], + ) -> None: + _assert_guid(guid) + ctx = Context(params.session, params.url) - uid = kwargs["guid"] - path = f"v1/content/{uid}" - super().__init__(ctx, path, **kwargs) + path = f"v1/content/{guid}" + super().__init__(ctx, path, guid=guid, **kwargs) def __getitem__(self, key: Any) -> Any: v = super().__getitem__(key) @@ -53,6 +256,36 @@ def __getitem__(self, key: Any) -> Any: def oauth(self) -> ContentItemOAuth: return ContentItemOAuth(self.params, content_guid=self["guid"]) + @property + def repository(self) -> ContentItemRepository | None: + try: + return ContentItemRepository(self._ctx, content_guid=self["guid"]) + except ClientError: + return None + + def create_repository( + self, + **attrs: Unpack[ContentItemRepository._Attrs], + ) -> ContentItemRepository: + """Create repository. + + Parameters + ---------- + repository : str + URL for the respository. + branch : str, optional + The tracked Git branch. Default is 'main'. + directory : str, optional + Directory containing the content. Default is '.'. + polling : bool, optional + Indicates that the Git repository is regularly polled. Default is False. + + Returns + ------- + ContentItemRepository + """ + return ContentItemRepository._create(self._ctx, self["guid"], **attrs) + def delete(self) -> None: """Delete the content item.""" path = f"v1/content/{self['guid']}" @@ -95,7 +328,7 @@ def render(self) -> Task: -------- >>> render() """ - self.update() + self.update() # pyright: ignore[reportCallIssue] if self.is_rendered: variants = self._variants.find() @@ -124,7 +357,7 @@ def restart(self) -> None: -------- >>> restart() """ - self.update() + self.update() # pyright: ignore[reportCallIssue] if self.is_interactive: unix_epoch_in_seconds = str(int(time.time())) @@ -140,40 +373,9 @@ def restart(self) -> None: f"Restart not supported for this application mode: {self['app_mode']}. Did you need to use the 'render()' method instead? Note that some application modes do not support 'render()' or 'restart()'.", ) - @overload def update( self, - *, - # Required argument - name: str, - # Content Metadata - title: Optional[str] = None, - description: Optional[str] = None, - access_type: Literal["all", "acl", "logged_in"] = "acl", - owner_guid: Optional[str] = None, - # Timeout Settings - connection_timeout: Optional[int] = None, - read_timeout: Optional[int] = None, - init_timeout: Optional[int] = None, - idle_timeout: Optional[int] = None, - # Process and Resource Limits - max_processes: Optional[int] = None, - min_processes: Optional[int] = None, - max_conns_per_process: Optional[int] = None, - load_factor: Optional[float] = None, - cpu_request: Optional[float] = None, - cpu_limit: Optional[float] = None, - memory_request: Optional[int] = None, - memory_limit: Optional[int] = None, - amd_gpu_limit: Optional[int] = None, - nvidia_gpu_limit: Optional[int] = None, - # Execution Settings - run_as: Optional[str] = None, - run_as_current_user: Optional[bool] = False, - default_image_name: Optional[str] = None, - default_r_environment_management: Optional[bool] = None, - default_py_environment_management: Optional[bool] = None, - service_account_name: Optional[str] = None, + **attrs: Unpack[ContentItem._Attrs], ) -> None: """Update the content item. @@ -234,15 +436,8 @@ def update( ------- None """ - - @overload - def update(self, **attributes: Any) -> None: - """Update the content.""" - - def update(self, **attributes: Any) -> None: - """Update the content.""" url = self.params.url + f"v1/content/{self['guid']}" - response = self.params.session.patch(url, json=attributes) + response = self.params.session.patch(url, json=attrs) super().update(**response.json()) # Relationships @@ -331,39 +526,9 @@ def count(self) -> int: """ return len(self.find()) - @overload def create( self, - *, - # Required argument - name: str, - # Content Metadata - title: Optional[str] = None, - description: Optional[str] = None, - access_type: Literal["all", "acl", "logged_in"] = "acl", - # Timeout Settings - connection_timeout: Optional[int] = None, - read_timeout: Optional[int] = None, - init_timeout: Optional[int] = None, - idle_timeout: Optional[int] = None, - # Process and Resource Limits - max_processes: Optional[int] = None, - min_processes: Optional[int] = None, - max_conns_per_process: Optional[int] = None, - load_factor: Optional[float] = None, - cpu_request: Optional[float] = None, - cpu_limit: Optional[float] = None, - memory_request: Optional[int] = None, - memory_limit: Optional[int] = None, - amd_gpu_limit: Optional[int] = None, - nvidia_gpu_limit: Optional[int] = None, - # Execution Settings - run_as: Optional[str] = None, - run_as_current_user: Optional[bool] = False, - default_image_name: Optional[str] = None, - default_r_environment_management: Optional[bool] = None, - default_py_environment_management: Optional[bool] = None, - service_account_name: Optional[str] = None, + **attrs: Unpack[ContentItem._AttrsCreate], ) -> ContentItem: """Create content. @@ -417,23 +582,8 @@ def create( Manage Python environment for the content. Default is None. service_account_name : str, optional Kubernetes service account name for running content. Default is None. - - Returns - ------- - ContentItem - """ - - @overload - def create(self, **attributes) -> ContentItem: - """Create a content item. - - Returns - ------- - ContentItem - """ - - def create(self, **attributes) -> ContentItem: - """Create a content item. + **attributes : Any + Additional attributes. Returns ------- @@ -441,7 +591,7 @@ def create(self, **attributes) -> ContentItem: """ path = "v1/content" url = self.params.url + path - response = self.params.session.post(url, json=attributes) + response = self.params.session.post(url, json=attrs) return ContentItem(self.params, **response.json()) @overload @@ -538,42 +688,13 @@ def find(self, include: Optional[str | list[Any]] = None, **conditions) -> List[ for result in response.json() ] - @overload def find_by( self, - *, - # Required - name: str, - # Content Metadata - title: Optional[str] = None, - description: Optional[str] = None, - access_type: Literal["all", "acl", "logged_in"] = "acl", - owner_guid: Optional[str] = None, - # Timeout Settings - connection_timeout: Optional[int] = None, - read_timeout: Optional[int] = None, - init_timeout: Optional[int] = None, - idle_timeout: Optional[int] = None, - # Process and Resource Limits - max_processes: Optional[int] = None, - min_processes: Optional[int] = None, - max_conns_per_process: Optional[int] = None, - load_factor: Optional[float] = None, - cpu_request: Optional[float] = None, - cpu_limit: Optional[float] = None, - memory_request: Optional[int] = None, - memory_limit: Optional[int] = None, - amd_gpu_limit: Optional[int] = None, - nvidia_gpu_limit: Optional[int] = None, - # Execution Settings - run_as: Optional[str] = None, - run_as_current_user: Optional[bool] = False, - default_image_name: Optional[str] = None, - default_r_environment_management: Optional[bool] = None, - default_py_environment_management: Optional[bool] = None, - service_account_name: Optional[str] = None, + **attrs: Unpack[ContentItem._AttrsNotRequired], ) -> Optional[ContentItem]: - """Find the first content record matching the specified attributes. There is no implied ordering so if order matters, you should find it yourself. + """Find the first content record matching the specified attributes. + + There is no implied ordering so if order matters, you should find it yourself. Parameters ---------- @@ -628,23 +749,6 @@ def find_by( service_account_name : str, optional Kubernetes service account name for running content. - Returns - ------- - Optional[ContentItem] - """ - - @overload - def find_by(self, **attributes) -> Optional[ContentItem]: - """Find the first content record matching the specified attributes. There is no implied ordering so if order matters, you should find it yourself. - - Returns - ------- - Optional[ContentItem] - """ - - def find_by(self, **attributes) -> Optional[ContentItem]: - """Find the first content record matching the specified attributes. There is no implied ordering so if order matters, you should find it yourself. - Returns ------- Optional[ContentItem] @@ -653,11 +757,10 @@ def find_by(self, **attributes) -> Optional[ContentItem]: ------- >>> find_by(name="example-content-name") """ + attr_items = attrs.items() results = self.find() results = ( - result - for result in results - if all(item in result.items() for item in attributes.items()) + result for result in results if all(item in result.items() for item in attr_items) ) return next(results, None) diff --git a/src/posit/connect/cursors.py b/src/posit/connect/cursors.py index f1228d0c..3c8a3c62 100644 --- a/src/posit/connect/cursors.py +++ b/src/posit/connect/cursors.py @@ -18,7 +18,10 @@ class CursorPage: class CursorPaginator: def __init__( - self, session: requests.Session, url: str, params: dict[str, Any] | None = None + self, + session: requests.Session, + url: str, + params: dict[str, Any] | None = None, ) -> None: if params is None: params = {} diff --git a/src/posit/connect/hooks.py b/src/posit/connect/hooks.py index 402c30c6..a2561f9a 100644 --- a/src/posit/connect/hooks.py +++ b/src/posit/connect/hooks.py @@ -6,7 +6,12 @@ from .errors import ClientError -def handle_errors(response: Response, *args, **kwargs) -> Response: +def handle_errors( + response: Response, + # Arguments for the hook callback signature + *request_hook_args, # noqa: ARG001 + **request_hook_kwargs, # noqa: ARG001 +) -> Response: if response.status_code >= 400: try: data = response.json() @@ -22,7 +27,12 @@ def handle_errors(response: Response, *args, **kwargs) -> Response: return response -def check_for_deprecation_header(response: Response, *args, **kwargs) -> Response: +def check_for_deprecation_header( + response: Response, + # Extra arguments for the hook callback signature + *args, # noqa: ARG001 + **kwargs, # noqa: ARG001 +) -> Response: """ Check for deprecation warnings from the server. diff --git a/src/posit/connect/jobs.py b/src/posit/connect/jobs.py index 6778a88b..bcf97092 100644 --- a/src/posit/connect/jobs.py +++ b/src/posit/connect/jobs.py @@ -1,8 +1,7 @@ import posixpath -from typing import Any, Literal, Optional, TypedDict, overload - -from typing_extensions import NotRequired, Required, Unpack +from typing import Any, Literal, Optional, overload +from ._typing_extensions import NotRequired, Required, TypedDict, Unpack from .context import Context from .resources import Active, ActiveFinderMethods, ActiveSequence, Resource diff --git a/src/posit/connect/oauth/integrations.py b/src/posit/connect/oauth/integrations.py index dff72890..ccbaaf73 100644 --- a/src/posit/connect/oauth/integrations.py +++ b/src/posit/connect/oauth/integrations.py @@ -2,9 +2,8 @@ from typing import List, Optional, overload -from posit.connect.oauth.associations import IntegrationAssociations - from ..resources import Resource, Resources +from .associations import IntegrationAssociations class Integration(Resource): diff --git a/src/posit/connect/oauth/oauth.py b/src/posit/connect/oauth/oauth.py index 34e6bf2c..18cd7f5c 100644 --- a/src/posit/connect/oauth/oauth.py +++ b/src/posit/connect/oauth/oauth.py @@ -1,7 +1,8 @@ from __future__ import annotations -from typing import Optional, TypedDict +from typing import Optional +from .._typing_extensions import TypedDict from ..resources import ResourceParameters, Resources from .integrations import Integrations from .sessions import Sessions diff --git a/src/posit/connect/paginator.py b/src/posit/connect/paginator.py index 16ce2959..2889801c 100644 --- a/src/posit/connect/paginator.py +++ b/src/posit/connect/paginator.py @@ -42,7 +42,10 @@ class Paginator: """ def __init__( - self, session: requests.Session, url: str, params: dict[str, Any] | None = None + self, + session: requests.Session, + url: str, + params: dict[str, Any] | None = None, ) -> None: if params is None: params = {} diff --git a/src/posit/connect/resources.py b/src/posit/connect/resources.py index 2e54ecc2..49e4b657 100644 --- a/src/posit/connect/resources.py +++ b/src/posit/connect/resources.py @@ -1,14 +1,17 @@ +from __future__ import annotations + import posixpath import warnings from abc import ABC, abstractmethod from dataclasses import dataclass -from typing import Any, Generic, List, Optional, Sequence, TypeVar, overload +from typing import TYPE_CHECKING, Any, Generic, List, Optional, Sequence, TypeVar, overload -import requests -from typing_extensions import Self +if TYPE_CHECKING: + import requests -from .context import Context -from .urls import Url + from ._typing_extensions import Self + from .context import Context + from .urls import Url @dataclass(frozen=True) @@ -18,6 +21,8 @@ class ResourceParameters: Attributes ---------- session: requests.Session + A `requests.Session` object. Provides cookie persistence, connection-pooling, and + configuration. url: str The Connect API base URL (e.g., https://connect.example.com/__api__) """ @@ -162,12 +167,12 @@ def __getitem__(self, index: slice) -> Sequence[T]: ... def __getitem__(self, index): return self._data[index] - def __iter__(self): - return iter(self._data) - def __len__(self) -> int: return len(self._data) + def __iter__(self): + return iter(self._data) + def __str__(self) -> str: return str(self._data) @@ -175,7 +180,7 @@ def __repr__(self) -> str: return repr(self._data) -class ActiveFinderMethods(ActiveSequence[T], ABC): +class ActiveFinderMethods(ActiveSequence[T]): """Finder methods. Provides various finder methods for locating records in any endpoint supporting HTTP GET requests. @@ -201,7 +206,7 @@ def find(self, uid) -> T: result = response.json() return self._to_instance(result) - def find_by(self, **conditions: Any) -> Optional[T]: + def find_by(self, **conditions: Any) -> T | None: """ Find the first record matching the specified conditions. diff --git a/src/posit/connect/users.py b/src/posit/connect/users.py index 4b085702..cc206da8 100644 --- a/src/posit/connect/users.py +++ b/src/posit/connect/users.py @@ -2,11 +2,10 @@ from __future__ import annotations -from typing import List, Literal, TypedDict - -from typing_extensions import NotRequired, Required, Unpack +from typing import List, Literal from . import me +from ._typing_extensions import NotRequired, Required, TypedDict, Unpack from .content import Content from .paginator import Paginator from .resources import Resource, ResourceParameters, Resources diff --git a/src/posit/connect/vanities.py b/src/posit/connect/vanities.py index d5ad8506..c0345eed 100644 --- a/src/posit/connect/vanities.py +++ b/src/posit/connect/vanities.py @@ -1,7 +1,6 @@ -from typing import Callable, List, Optional, TypedDict - -from typing_extensions import NotRequired, Required, Unpack +from typing import Callable, List, Optional +from ._typing_extensions import NotRequired, Required, TypedDict, Unpack from .errors import ClientError from .resources import Resource, ResourceParameters, Resources diff --git a/tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/repository.json b/tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/repository.json new file mode 100644 index 00000000..b3d91b34 --- /dev/null +++ b/tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/repository.json @@ -0,0 +1,6 @@ +{ + "repository": "https://github.com/posit-dev/posit-sdk-py/", + "branch": "main", + "directory": "integration/resources/connect/bundles/example-flask-minimal", + "polling": true +} diff --git a/tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/repository_patch.json b/tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/repository_patch.json new file mode 100644 index 00000000..9ec34cc9 --- /dev/null +++ b/tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/repository_patch.json @@ -0,0 +1,6 @@ +{ + "repository": "https://github.com/posit-dev/posit-sdk-py/", + "branch": "testing-main", + "directory": "integration/resources/connect/bundles/example-flask-minimal", + "polling": true +} diff --git a/tests/posit/connect/api.py b/tests/posit/connect/api.py index b71ea1ee..de2651b0 100644 --- a/tests/posit/connect/api.py +++ b/tests/posit/connect/api.py @@ -2,8 +2,10 @@ import pyjson5 as json +from posit.connect._json import Jsonifiable, JsonifiableDict, JsonifiableList -def load_mock(path: str) -> dict: + +def load_mock(path: str) -> Jsonifiable: """ Load mock data from a file. @@ -20,7 +22,7 @@ def load_mock(path: str) -> dict: Returns ------- - dict + Jsonifiable The parsed data from the JSONC file. Examples @@ -31,5 +33,17 @@ def load_mock(path: str) -> dict: return json.loads((Path(__file__).parent / "__api__" / path).read_text()) +def load_mock_dict(path: str) -> JsonifiableDict: + result = load_mock(path) + assert isinstance(result, dict) + return result + + +def load_mock_list(path: str) -> JsonifiableList: + result = load_mock(path) + assert isinstance(result, list) + return result + + def get_path(path: str) -> Path: return Path(__file__).parent / "__api__" / path diff --git a/tests/posit/connect/external/test_databricks.py b/tests/posit/connect/external/test_databricks.py index 777ff757..05c9c670 100644 --- a/tests/posit/connect/external/test_databricks.py +++ b/tests/posit/connect/external/test_databricks.py @@ -6,12 +6,13 @@ from posit.connect import Client from posit.connect.external.databricks import ( CredentialsProvider, + CredentialsStrategy, PositCredentialsProvider, PositCredentialsStrategy, ) -class mock_strategy: +class mock_strategy(CredentialsStrategy): def auth_type(self) -> str: return "local" diff --git a/tests/posit/connect/metrics/test_shiny_usage.py b/tests/posit/connect/metrics/test_shiny_usage.py index 20c238c6..01988a21 100644 --- a/tests/posit/connect/metrics/test_shiny_usage.py +++ b/tests/posit/connect/metrics/test_shiny_usage.py @@ -6,15 +6,19 @@ from posit.connect.metrics import shiny_usage from posit.connect.resources import ResourceParameters +from posit.connect.urls import Url -from ..api import load_mock # type: ignore +from ..api import load_mock, load_mock_dict class TestShinyUsageEventAttributes: + @classmethod def setup_class(cls): + results = load_mock_dict("v1/instrumentation/shiny/usage?limit=500.json")["results"] + assert isinstance(results, list) cls.event = shiny_usage.ShinyUsageEvent( mock.Mock(), - **load_mock("v1/instrumentation/shiny/usage?limit=500.json")["results"][0], + **results[0], ) def test_content_guid(self): @@ -37,34 +41,34 @@ class TestShinyUsageFind: @responses.activate def test(self): # behavior - mock_get = [None] * 2 - mock_get[0] = responses.get( - "https://connect.example/__api__/v1/instrumentation/shiny/usage", - json=load_mock("v1/instrumentation/shiny/usage?limit=500.json"), - match=[ - matchers.query_param_matcher( - { - "limit": 500, - }, - ), - ], - ) - - mock_get[1] = responses.get( - "https://connect.example/__api__/v1/instrumentation/shiny/usage", - json=load_mock("v1/instrumentation/shiny/usage?limit=500&next=23948901087.json"), - match=[ - matchers.query_param_matcher( - { - "next": "23948901087", - "limit": 500, - }, - ), - ], - ) + mock_get = [ + responses.get( + "https://connect.example/__api__/v1/instrumentation/shiny/usage", + json=load_mock("v1/instrumentation/shiny/usage?limit=500.json"), + match=[ + matchers.query_param_matcher( + { + "limit": 500, + }, + ), + ], + ), + responses.get( + "https://connect.example/__api__/v1/instrumentation/shiny/usage", + json=load_mock("v1/instrumentation/shiny/usage?limit=500&next=23948901087.json"), + match=[ + matchers.query_param_matcher( + { + "next": "23948901087", + "limit": 500, + }, + ), + ], + ), + ] # setup - params = ResourceParameters(requests.Session(), "https://connect.example/__api__") + params = ResourceParameters(requests.Session(), Url("https://connect.example/__api__")) # invoke events = shiny_usage.ShinyUsage(params).find() @@ -79,34 +83,34 @@ class TestShinyUsageFindOne: @responses.activate def test(self): # behavior - mock_get = [None] * 2 - mock_get[0] = responses.get( - "https://connect.example/__api__/v1/instrumentation/shiny/usage", - json=load_mock("v1/instrumentation/shiny/usage?limit=500.json"), - match=[ - matchers.query_param_matcher( - { - "limit": 500, - }, - ), - ], - ) - - mock_get[1] = responses.get( - "https://connect.example/__api__/v1/instrumentation/shiny/usage", - json=load_mock("v1/instrumentation/shiny/usage?limit=500&next=23948901087.json"), - match=[ - matchers.query_param_matcher( - { - "next": "23948901087", - "limit": 500, - }, - ), - ], - ) + mock_get = [ + responses.get( + "https://connect.example/__api__/v1/instrumentation/shiny/usage", + json=load_mock("v1/instrumentation/shiny/usage?limit=500.json"), + match=[ + matchers.query_param_matcher( + { + "limit": 500, + }, + ), + ], + ), + responses.get( + "https://connect.example/__api__/v1/instrumentation/shiny/usage", + json=load_mock("v1/instrumentation/shiny/usage?limit=500&next=23948901087.json"), + match=[ + matchers.query_param_matcher( + { + "next": "23948901087", + "limit": 500, + }, + ), + ], + ), + ] # setup - params = ResourceParameters(requests.Session(), "https://connect.example/__api__") + params = ResourceParameters(requests.Session(), Url("https://connect.example/__api__")) # invoke event = shiny_usage.ShinyUsage(params).find_one() diff --git a/tests/posit/connect/metrics/test_usage.py b/tests/posit/connect/metrics/test_usage.py index 7b113770..1a47e09b 100644 --- a/tests/posit/connect/metrics/test_usage.py +++ b/tests/posit/connect/metrics/test_usage.py @@ -7,20 +7,26 @@ from posit import connect from posit.connect.metrics import shiny_usage, usage, visits -from ..api import load_mock # type: ignore +from ..api import load_mock, load_mock_dict class TestUsageEventFromEvent: def test(self): with pytest.raises(TypeError): - usage.UsageEvent.from_event(None) + usage.UsageEvent.from_event( + None # pyright: ignore[reportArgumentType] + ) class TestUsageEventFromVisitEvent: + @classmethod def setup_class(cls): + results = load_mock_dict("v1/instrumentation/content/visits?limit=500.json")["results"] + assert isinstance(results, list) + visit_event = visits.VisitEvent( mock.Mock(), - **load_mock("v1/instrumentation/content/visits?limit=500.json")["results"][0], + **results[0], ) cls.view_event = usage.UsageEvent.from_visit_event(visit_event) @@ -53,10 +59,13 @@ def test_path(self): class TestUsageEventFromShinyUsageEvent: + @classmethod def setup_class(cls): + results = load_mock_dict("v1/instrumentation/shiny/usage?limit=500.json")["results"] + assert isinstance(results, list) visit_event = shiny_usage.ShinyUsageEvent( mock.Mock(), - **load_mock("v1/instrumentation/shiny/usage?limit=500.json")["results"][0], + **results[0], ) cls.view_event = usage.UsageEvent.from_shiny_usage_event(visit_event) @@ -92,57 +101,56 @@ class TestUsageFind: @responses.activate def test(self): # behavior - mock_get = [None] * 4 - - mock_get[0] = responses.get( - "https://connect.example/__api__/v1/instrumentation/content/visits", - json=load_mock("v1/instrumentation/content/visits?limit=500.json"), - match=[ - matchers.query_param_matcher( - { - "limit": 500, - }, + mock_get = [ + responses.get( + "https://connect.example/__api__/v1/instrumentation/content/visits", + json=load_mock("v1/instrumentation/content/visits?limit=500.json"), + match=[ + matchers.query_param_matcher( + { + "limit": 500, + }, + ), + ], + ), + responses.get( + "https://connect.example/__api__/v1/instrumentation/content/visits", + json=load_mock( + "v1/instrumentation/content/visits?limit=500&next=23948901087.json" ), - ], - ) - - mock_get[1] = responses.get( - "https://connect.example/__api__/v1/instrumentation/content/visits", - json=load_mock("v1/instrumentation/content/visits?limit=500&next=23948901087.json"), - match=[ - matchers.query_param_matcher( - { - "next": "23948901087", - "limit": 500, - }, - ), - ], - ) - - mock_get[2] = responses.get( - "https://connect.example/__api__/v1/instrumentation/shiny/usage", - json=load_mock("v1/instrumentation/shiny/usage?limit=500.json"), - match=[ - matchers.query_param_matcher( - { - "limit": 500, - }, - ), - ], - ) - - mock_get[3] = responses.get( - "https://connect.example/__api__/v1/instrumentation/shiny/usage", - json=load_mock("v1/instrumentation/shiny/usage?limit=500&next=23948901087.json"), - match=[ - matchers.query_param_matcher( - { - "next": "23948901087", - "limit": 500, - }, - ), - ], - ) + match=[ + matchers.query_param_matcher( + { + "next": "23948901087", + "limit": 500, + }, + ), + ], + ), + responses.get( + "https://connect.example/__api__/v1/instrumentation/shiny/usage", + json=load_mock("v1/instrumentation/shiny/usage?limit=500.json"), + match=[ + matchers.query_param_matcher( + { + "limit": 500, + }, + ), + ], + ), + responses.get( + "https://connect.example/__api__/v1/instrumentation/shiny/usage", + json=load_mock("v1/instrumentation/shiny/usage?limit=500&next=23948901087.json"), + match=[ + matchers.query_param_matcher( + { + "next": "23948901087", + "limit": 500, + }, + ), + ], + ), + ] # setup c = connect.Client("https://connect.example", "12345") @@ -162,57 +170,56 @@ class TestUsageFindOne: @responses.activate def test(self): # behavior - mock_get = [None] * 4 - - mock_get[0] = responses.get( - "https://connect.example/__api__/v1/instrumentation/content/visits", - json=load_mock("v1/instrumentation/content/visits?limit=500.json"), - match=[ - matchers.query_param_matcher( - { - "limit": 500, - }, - ), - ], - ) - - mock_get[1] = responses.get( - "https://connect.example/__api__/v1/instrumentation/content/visits", - json=load_mock("v1/instrumentation/content/visits?limit=500&next=23948901087.json"), - match=[ - matchers.query_param_matcher( - { - "next": "23948901087", - "limit": 500, - }, + mock_get = [ + responses.get( + "https://connect.example/__api__/v1/instrumentation/content/visits", + json=load_mock("v1/instrumentation/content/visits?limit=500.json"), + match=[ + matchers.query_param_matcher( + { + "limit": 500, + }, + ), + ], + ), + responses.get( + "https://connect.example/__api__/v1/instrumentation/content/visits", + json=load_mock( + "v1/instrumentation/content/visits?limit=500&next=23948901087.json" ), - ], - ) - - mock_get[2] = responses.get( - "https://connect.example/__api__/v1/instrumentation/shiny/usage", - json=load_mock("v1/instrumentation/shiny/usage?limit=500.json"), - match=[ - matchers.query_param_matcher( - { - "limit": 500, - }, - ), - ], - ) - - mock_get[3] = responses.get( - "https://connect.example/__api__/v1/instrumentation/shiny/usage", - json=load_mock("v1/instrumentation/shiny/usage?limit=500&next=23948901087.json"), - match=[ - matchers.query_param_matcher( - { - "next": "23948901087", - "limit": 500, - }, - ), - ], - ) + match=[ + matchers.query_param_matcher( + { + "next": "23948901087", + "limit": 500, + }, + ), + ], + ), + responses.get( + "https://connect.example/__api__/v1/instrumentation/shiny/usage", + json=load_mock("v1/instrumentation/shiny/usage?limit=500.json"), + match=[ + matchers.query_param_matcher( + { + "limit": 500, + }, + ), + ], + ), + responses.get( + "https://connect.example/__api__/v1/instrumentation/shiny/usage", + json=load_mock("v1/instrumentation/shiny/usage?limit=500&next=23948901087.json"), + match=[ + matchers.query_param_matcher( + { + "next": "23948901087", + "limit": 500, + }, + ), + ], + ), + ] # setup c = connect.Client("https://connect.example", "12345") @@ -231,18 +238,19 @@ def test(self): @responses.activate def test_none(self): # behavior - mock_get = [None] * 2 - - # return an empty result set to push through the iterator - mock_get[0] = responses.get( - "https://connect.example/__api__/v1/instrumentation/content/visits", - json=load_mock("v1/instrumentation/content/visits?limit=500&next=23948901087.json"), - ) - - mock_get[1] = responses.get( - "https://connect.example/__api__/v1/instrumentation/shiny/usage", - json=load_mock("v1/instrumentation/shiny/usage?limit=500&next=23948901087.json"), - ) + mock_get = [ + # return an empty result set to push through the iterator + responses.get( + "https://connect.example/__api__/v1/instrumentation/content/visits", + json=load_mock( + "v1/instrumentation/content/visits?limit=500&next=23948901087.json" + ), + ), + responses.get( + "https://connect.example/__api__/v1/instrumentation/shiny/usage", + json=load_mock("v1/instrumentation/shiny/usage?limit=500&next=23948901087.json"), + ), + ] # setup c = connect.Client("https://connect.example", "12345") diff --git a/tests/posit/connect/metrics/test_visits.py b/tests/posit/connect/metrics/test_visits.py index b75e04f2..a8c12449 100644 --- a/tests/posit/connect/metrics/test_visits.py +++ b/tests/posit/connect/metrics/test_visits.py @@ -6,15 +6,21 @@ from posit.connect.metrics import visits from posit.connect.resources import ResourceParameters +from posit.connect.urls import Url -from ..api import load_mock # type: ignore +from ..api import load_mock, load_mock_dict class TestVisitAttributes: + @classmethod def setup_class(cls): + results = load_mock_dict("v1/instrumentation/content/visits?limit=500.json")["results"] + assert isinstance(results, list) + first_result_dict = results[0] + assert isinstance(first_result_dict, dict) cls.visit = visits.VisitEvent( mock.Mock(), - **load_mock("v1/instrumentation/content/visits?limit=500.json")["results"][0], + **first_result_dict, ) def test_content_guid(self): @@ -46,34 +52,36 @@ class TestVisitsFind: @responses.activate def test(self): # behavior - mock_get = [None] * 2 - mock_get[0] = responses.get( - "https://connect.example/__api__/v1/instrumentation/content/visits", - json=load_mock("v1/instrumentation/content/visits?limit=500.json"), - match=[ - matchers.query_param_matcher( - { - "limit": 500, - }, + mock_get = [ + responses.get( + "https://connect.example/__api__/v1/instrumentation/content/visits", + json=load_mock("v1/instrumentation/content/visits?limit=500.json"), + match=[ + matchers.query_param_matcher( + { + "limit": 500, + }, + ), + ], + ), + responses.get( + "https://connect.example/__api__/v1/instrumentation/content/visits", + json=load_mock( + "v1/instrumentation/content/visits?limit=500&next=23948901087.json" ), - ], - ) - - mock_get[1] = responses.get( - "https://connect.example/__api__/v1/instrumentation/content/visits", - json=load_mock("v1/instrumentation/content/visits?limit=500&next=23948901087.json"), - match=[ - matchers.query_param_matcher( - { - "next": "23948901087", - "limit": 500, - }, - ), - ], - ) + match=[ + matchers.query_param_matcher( + { + "next": "23948901087", + "limit": 500, + }, + ), + ], + ), + ] # setup - params = ResourceParameters(requests.Session(), "https://connect.example/__api__") + params = ResourceParameters(requests.Session(), Url("https://connect.example/__api__")) # invoke events = visits.Visits(params).find() @@ -88,34 +96,36 @@ class TestVisitsFindOne: @responses.activate def test(self): # behavior - mock_get = [None] * 2 - mock_get[0] = responses.get( - "https://connect.example/__api__/v1/instrumentation/content/visits", - json=load_mock("v1/instrumentation/content/visits?limit=500.json"), - match=[ - matchers.query_param_matcher( - { - "limit": 500, - }, + mock_get = [ + responses.get( + "https://connect.example/__api__/v1/instrumentation/content/visits", + json=load_mock("v1/instrumentation/content/visits?limit=500.json"), + match=[ + matchers.query_param_matcher( + { + "limit": 500, + }, + ), + ], + ), + responses.get( + "https://connect.example/__api__/v1/instrumentation/content/visits", + json=load_mock( + "v1/instrumentation/content/visits?limit=500&next=23948901087.json" ), - ], - ) - - mock_get[1] = responses.get( - "https://connect.example/__api__/v1/instrumentation/content/visits", - json=load_mock("v1/instrumentation/content/visits?limit=500&next=23948901087.json"), - match=[ - matchers.query_param_matcher( - { - "next": "23948901087", - "limit": 500, - }, - ), - ], - ) + match=[ + matchers.query_param_matcher( + { + "next": "23948901087", + "limit": 500, + }, + ), + ], + ), + ] # setup - params = ResourceParameters(requests.Session(), "https://connect.example/__api__") + params = ResourceParameters(requests.Session(), Url("https://connect.example/__api__")) # invoke event = visits.Visits(params).find_one() diff --git a/tests/posit/connect/oauth/test_associations.py b/tests/posit/connect/oauth/test_associations.py index 7ea13a70..5f26f9cb 100644 --- a/tests/posit/connect/oauth/test_associations.py +++ b/tests/posit/connect/oauth/test_associations.py @@ -5,14 +5,14 @@ from posit.connect.client import Client from posit.connect.oauth.associations import Association -from ..api import load_mock +from ..api import load_mock, load_mock_list class TestAssociationAttributes: @classmethod def setup_class(cls): guid = "22644575-a27b-4118-ad06-e24459b05126" - fake_items = load_mock(f"v1/oauth/integrations/{guid}/associations.json") + fake_items = load_mock_list(f"v1/oauth/integrations/{guid}/associations.json") assert len(fake_items) == 1 fake_item = fake_items[0] diff --git a/tests/posit/connect/oauth/test_integrations.py b/tests/posit/connect/oauth/test_integrations.py index a2a6d223..3f136f4e 100644 --- a/tests/posit/connect/oauth/test_integrations.py +++ b/tests/posit/connect/oauth/test_integrations.py @@ -3,7 +3,7 @@ from posit.connect.client import Client -from ..api import load_mock # type: ignore +from ..api import load_mock, load_mock_dict class TestIntegrationDelete: @@ -50,7 +50,7 @@ def test(self): new_name = "New Name" - fake_integration = load_mock(f"v1/oauth/integrations/{guid}.json") + fake_integration = load_mock_dict(f"v1/oauth/integrations/{guid}.json") fake_integration.update(name=new_name) assert fake_integration["name"] == new_name @@ -69,7 +69,7 @@ class TestIntegrationsCreate: def test(self): # data guid = "22644575-a27b-4118-ad06-e24459b05126" - fake_integration = load_mock(f"v1/oauth/integrations/{guid}.json") + fake_integration = load_mock_dict(f"v1/oauth/integrations/{guid}.json") # behavior mock_create = responses.post( diff --git a/tests/posit/connect/oauth/test_oauth.py b/tests/posit/connect/oauth/test_oauth.py index fce2f42b..f233e0bb 100644 --- a/tests/posit/connect/oauth/test_oauth.py +++ b/tests/posit/connect/oauth/test_oauth.py @@ -25,4 +25,6 @@ def test_get_credentials(self): ) c = Client(api_key="12345", url="https://connect.example/") c.ctx.version = None - assert c.oauth.get_credentials("cit")["access_token"] == "viewer-token" + creds = c.oauth.get_credentials("cit") + assert "access_token" in creds + assert creds["access_token"] == "viewer-token" diff --git a/tests/posit/connect/oauth/test_sessions.py b/tests/posit/connect/oauth/test_sessions.py index 9af1656e..053c3c52 100644 --- a/tests/posit/connect/oauth/test_sessions.py +++ b/tests/posit/connect/oauth/test_sessions.py @@ -3,7 +3,7 @@ from posit.connect.client import Client -from ..api import load_mock # type: ignore +from ..api import load_mock class TestSessionDelete: diff --git a/tests/posit/connect/test_api_endpoint.py b/tests/posit/connect/test_api_endpoint.py new file mode 100644 index 00000000..2281084f --- /dev/null +++ b/tests/posit/connect/test_api_endpoint.py @@ -0,0 +1,18 @@ +import pytest + +from posit.connect._api import ReadOnlyDict + + +class TestApiEndpoint: + def test_read_only(self): + obj = ReadOnlyDict({}) + + assert len(obj) == 0 + + assert obj.get("foo", "bar") == "bar" + + with pytest.raises(NotImplementedError): + obj["foo"] = "baz" + + eq_obj = ReadOnlyDict({"foo": "bar", "a": 1}) + assert eq_obj == {"foo": "bar", "a": 1} diff --git a/tests/posit/connect/test_bundles.py b/tests/posit/connect/test_bundles.py index 2f8031e2..9188d1a6 100644 --- a/tests/posit/connect/test_bundles.py +++ b/tests/posit/connect/test_bundles.py @@ -7,7 +7,7 @@ from posit.connect import Client -from .api import get_path, load_mock # type: ignore +from .api import get_path, load_mock class TestBundleDelete: @@ -153,7 +153,9 @@ def test_output_as_io(self): # invoke file = io.BytesIO() - buffer = io.BufferedWriter(file) + buffer = io.BufferedWriter( + file # pyright: ignore[reportArgumentType] + ) bundle.download(buffer) buffer.seek(0) @@ -191,7 +193,9 @@ def test_invalid_arguments(self): # invoke with pytest.raises(TypeError): - bundle.download(None) + bundle.download( + None # pyright: ignore[reportArgumentType] + ) # assert assert mock_content_get.call_count == 1 @@ -279,7 +283,9 @@ def test_invalid_arguments(self): # invoke with pytest.raises(TypeError): - content.bundles.create(None) + content.bundles.create( + None # pyright: ignore[reportArgumentType] + ) class TestBundlesFind: diff --git a/tests/posit/connect/test_client.py b/tests/posit/connect/test_client.py index 5425a301..e6a07d3c 100644 --- a/tests/posit/connect/test_client.py +++ b/tests/posit/connect/test_client.py @@ -1,3 +1,4 @@ +# pyright: reportFunctionMemberAccess=false from unittest.mock import MagicMock, patch import pytest @@ -5,7 +6,7 @@ from posit.connect import Client -from .api import load_mock # type: ignore +from .api import load_mock @pytest.fixture diff --git a/tests/posit/connect/test_content.py b/tests/posit/connect/test_content.py index 964d4258..926df753 100644 --- a/tests/posit/connect/test_content.py +++ b/tests/posit/connect/test_content.py @@ -1,16 +1,21 @@ import pytest +import requests import responses from responses import matchers from posit.connect.client import Client +from posit.connect.content import ContentItem, ContentItemRepository +from posit.connect.context import Context +from posit.connect.resources import ResourceParameters +from posit.connect.urls import Url -from .api import load_mock # type: ignore +from .api import load_mock, load_mock_dict class TestContentItemGetContentOwner: @responses.activate def test_owner(self): - mock_content = load_mock("v1/content/f2f37341-e21d-3d80-c698-a935ad614066.json") + mock_content = load_mock_dict("v1/content/f2f37341-e21d-3d80-c698-a935ad614066.json") responses.get( "https://connect.example/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066", json=mock_content, @@ -107,7 +112,7 @@ def test_update(self): assert content["guid"] == guid new_name = "New Name" - fake_content = load_mock(f"v1/content/{guid}.json") + fake_content = load_mock_dict(f"v1/content/{guid}.json") fake_content.update(name=new_name) responses.patch( f"https://connect.example/__api__/v1/content/{guid}", @@ -123,7 +128,7 @@ class TestContentCreate: def test(self): # data guid = "f2f37341-e21d-3d80-c698-a935ad614066" - fake_content_item = load_mock(f"v1/content/{guid}.json") + fake_content_item = load_mock_dict(f"v1/content/{guid}.json") # behavior responses.post( @@ -136,7 +141,9 @@ def test(self): client = Client(api_key="12345", url="https://connect.example/") # invoke - content_item = client.content.create(name=fake_content_item["name"]) + fake_name = fake_content_item["name"] + assert isinstance(fake_name, str) + content_item = client.content.create(name=fake_name) # assert assert content_item["name"] == fake_content_item["name"] @@ -427,7 +434,7 @@ def test(self): def test_app_mode_is_other(self): # data guid = "f2f37341-e21d-3d80-c698-a935ad614066" - fixture_content = load_mock(f"v1/content/{guid}.json") + fixture_content = load_mock_dict(f"v1/content/{guid}.json") fixture_content.update(app_mode="other") # behavior @@ -477,7 +484,7 @@ class TestRestart: def test(self): # data guid = "f2f37341-e21d-3d80-c698-a935ad614066" - fixture_content = load_mock(f"v1/content/{guid}.json") + fixture_content = load_mock_dict(f"v1/content/{guid}.json") fixture_content.update(app_mode="api") # behavior @@ -517,7 +524,7 @@ def test(self): def test_app_mode_is_other(self): # data guid = "f2f37341-e21d-3d80-c698-a935ad614066" - fixture_content = load_mock(f"v1/content/{guid}.json") + fixture_content = load_mock_dict(f"v1/content/{guid}.json") fixture_content.update(app_mode="other") # behavior @@ -542,3 +549,74 @@ def test_app_mode_is_other(self): # assert assert mock_get_content.call_count == 1 assert mock_patch_content.call_count == 1 + + +class TestContentRepository: + @property + def base_url(self): + return "http://connect.example" + + @property + def content_guid(self): + return "f2f37341-e21d-3d80-c698-a935ad614066" + + @property + def content_item(self): + return ContentItem(self.params, guid=self.content_guid) + + @property + def endpoint(self): + return f"{self.base_url}/__api__/v1/content/{self.content_guid}/repository" + + @property + def ctx(self): + return Context(requests.Session(), Url(self.base_url)) + + @property + def params(self): + return ResourceParameters(self.ctx.session, self.ctx.url) + + def mock_repository_info(self): + content_item = self.content_item + + mock_get = responses.get( + self.endpoint, + json=load_mock_dict(f"v1/content/{self.content_guid}/repository.json"), + ) + repository_info = content_item.repository + + assert isinstance(repository_info, ContentItemRepository) + assert mock_get.call_count == 1 + + return repository_info + + @responses.activate + def test_repository_getter_returns_repository(self): + # Performs assertions in helper property method + self.mock_repository_info() + + @responses.activate + def test_repository_update(self): + repository_info = self.mock_repository_info() + + mock_patch = responses.patch( + self.endpoint, + json=load_mock_dict(f"v1/content/{self.content_guid}/repository_patch.json"), + ) + new_repository_info = repository_info.update(branch="testing-main") + assert mock_patch.call_count == 1 + + for key, value in repository_info.items(): + if key == "branch": + assert new_repository_info[key] == "testing-main" + else: + assert new_repository_info[key] == value + + @responses.activate + def test_repository_delete(self): + repository_info = self.mock_repository_info() + + mock_delete = responses.delete(self.endpoint) + repository_info.destroy() + + assert mock_delete.call_count == 1 diff --git a/tests/posit/connect/test_env.py b/tests/posit/connect/test_env.py index 0ad4b117..d5cf3121 100644 --- a/tests/posit/connect/test_env.py +++ b/tests/posit/connect/test_env.py @@ -4,7 +4,7 @@ from posit.connect import Client -from .api import load_mock # type: ignore +from .api import load_mock @responses.activate diff --git a/tests/posit/connect/test_groups.py b/tests/posit/connect/test_groups.py index 5ad02912..71954e56 100644 --- a/tests/posit/connect/test_groups.py +++ b/tests/posit/connect/test_groups.py @@ -3,7 +3,7 @@ from posit.connect.groups import Group -from .api import load_mock # type: ignore +from .api import load_mock_dict session = Mock() url = Mock() @@ -13,7 +13,7 @@ class TestGroupAttributes: @classmethod def setup_class(cls): guid = "6f300623-1e0c-48e6-a473-ddf630c0c0c3" - fake_item = load_mock(f"v1/groups/{guid}.json") + fake_item = load_mock_dict(f"v1/groups/{guid}.json") cls.item = Group(mock.Mock(), **fake_item) def test_guid(self): diff --git a/tests/posit/connect/test_jobs.py b/tests/posit/connect/test_jobs.py index 252bd2e8..4b1102ae 100644 --- a/tests/posit/connect/test_jobs.py +++ b/tests/posit/connect/test_jobs.py @@ -4,7 +4,7 @@ from posit.connect.client import Client -from .api import load_mock # type: ignore +from .api import load_mock class TestJobsMixin: diff --git a/tests/posit/connect/test_permissions.py b/tests/posit/connect/test_permissions.py index b8ebac16..0f3a390a 100644 --- a/tests/posit/connect/test_permissions.py +++ b/tests/posit/connect/test_permissions.py @@ -9,7 +9,7 @@ from posit.connect.resources import ResourceParameters from posit.connect.urls import Url -from .api import load_mock # type: ignore +from .api import load_mock, load_mock_dict, load_mock_list class TestPermissionDelete: @@ -26,7 +26,7 @@ def test(self): # setup params = ResourceParameters(requests.Session(), Url("https://connect.example/__api__")) - fake_permission = load_mock(f"v1/content/{content_guid}/permissions/{uid}.json") + fake_permission = load_mock_dict(f"v1/content/{content_guid}/permissions/{uid}.json") permission = Permission(params, **fake_permission) # invoke @@ -91,7 +91,7 @@ def test_role_update(self): uid = "94" content_guid = "f2f37341-e21d-3d80-c698-a935ad614066" - fake_permission = load_mock(f"v1/content/{content_guid}/permissions/{uid}.json") + fake_permission = load_mock_dict(f"v1/content/{content_guid}/permissions/{uid}.json") fake_permission.update(role=new_role) # define api behavior @@ -126,7 +126,7 @@ class TestPermissionsCount: def test(self): # test data content_guid = "f2f37341-e21d-3d80-c698-a935ad614066" - fake_permissions = load_mock(f"v1/content/{content_guid}/permissions.json") + fake_permissions = load_mock_list(f"v1/content/{content_guid}/permissions.json") # define api behavior responses.get( @@ -155,7 +155,7 @@ def test(self): principal_type = "user" role = "owner" fake_permission = { - **load_mock(f"v1/content/{content_guid}/permissions/{uid}.json"), + **load_mock_dict(f"v1/content/{content_guid}/permissions/{uid}.json"), "principal_guid": principal_guid, "principal_type": principal_type, "role": role, @@ -220,7 +220,7 @@ class TestPermissionsFindOne: def test(self): # test data content_guid = "f2f37341-e21d-3d80-c698-a935ad614066" - fake_permissions = load_mock(f"v1/content/{content_guid}/permissions.json") + fake_permissions = load_mock_list(f"v1/content/{content_guid}/permissions.json") # define api behavior responses.get( diff --git a/tests/posit/connect/test_tasks.py b/tests/posit/connect/test_tasks.py index 556727d1..5de1d2cf 100644 --- a/tests/posit/connect/test_tasks.py +++ b/tests/posit/connect/test_tasks.py @@ -1,19 +1,20 @@ from unittest import mock import responses -from responses import matchers +from responses import BaseResponse, matchers from posit import connect from posit.connect import tasks -from .api import load_mock # type: ignore +from .api import load_mock_dict class TestTaskAttributes: + @classmethod def setup_class(cls): cls.task = tasks.Task( mock.Mock(), - **load_mock("v1/tasks/jXhOhdm5OOSkGhJw.json"), + **load_mock_dict("v1/tasks/jXhOhdm5OOSkGhJw.json"), ) def test_id(self): @@ -47,16 +48,16 @@ def test(self): uid = "jXhOhdm5OOSkGhJw" # behavior - mock_tasks_get = [0] * 2 - mock_tasks_get[0] = responses.get( - f"https://connect.example/__api__/v1/tasks/{uid}", - json={**load_mock(f"v1/tasks/{uid}.json"), "finished": False}, - ) - - mock_tasks_get[1] = responses.get( - f"https://connect.example/__api__/v1/tasks/{uid}", - json={**load_mock(f"v1/tasks/{uid}.json"), "finished": True}, - ) + mock_tasks_get = [ + responses.get( + f"https://connect.example/__api__/v1/tasks/{uid}", + json={**load_mock_dict(f"v1/tasks/{uid}.json"), "finished": False}, + ), + responses.get( + f"https://connect.example/__api__/v1/tasks/{uid}", + json={**load_mock_dict(f"v1/tasks/{uid}.json"), "finished": True}, + ), + ] # setup c = connect.Client("https://connect.example", "12345") @@ -77,17 +78,17 @@ def test_with_params(self): params = {"first": 10, "wait": 10} # behavior - mock_tasks_get = [0] * 2 - mock_tasks_get[0] = responses.get( - f"https://connect.example/__api__/v1/tasks/{uid}", - json={**load_mock(f"v1/tasks/{uid}.json"), "finished": False}, - ) - - mock_tasks_get[1] = responses.get( - f"https://connect.example/__api__/v1/tasks/{uid}", - json={**load_mock(f"v1/tasks/{uid}.json"), "finished": True}, - match=[matchers.query_param_matcher(params)], - ) + mock_tasks_get = [ + responses.get( + f"https://connect.example/__api__/v1/tasks/{uid}", + json={**load_mock_dict(f"v1/tasks/{uid}.json"), "finished": False}, + ), + responses.get( + f"https://connect.example/__api__/v1/tasks/{uid}", + json={**load_mock_dict(f"v1/tasks/{uid}.json"), "finished": True}, + match=[matchers.query_param_matcher(params)], + ), + ] # setup c = connect.Client("https://connect.example", "12345") @@ -109,16 +110,16 @@ def test(self): uid = "jXhOhdm5OOSkGhJw" # behavior - mock_tasks_get = [0] * 2 - mock_tasks_get[0] = responses.get( - f"https://connect.example/__api__/v1/tasks/{uid}", - json={**load_mock(f"v1/tasks/{uid}.json"), "finished": False}, - ) - - mock_tasks_get[1] = responses.get( - f"https://connect.example/__api__/v1/tasks/{uid}", - json={**load_mock(f"v1/tasks/{uid}.json"), "finished": True}, - ) + mock_tasks_get = [ + responses.get( + f"https://connect.example/__api__/v1/tasks/{uid}", + json={**load_mock_dict(f"v1/tasks/{uid}.json"), "finished": False}, + ), + responses.get( + f"https://connect.example/__api__/v1/tasks/{uid}", + json={**load_mock_dict(f"v1/tasks/{uid}.json"), "finished": True}, + ), + ] # setup c = connect.Client("https://connect.example", "12345") @@ -140,9 +141,9 @@ def test(self): uid = "jXhOhdm5OOSkGhJw" # behavior - mock_tasks_get = responses.get( + mock_tasks_get: BaseResponse = responses.get( f"https://connect.example/__api__/v1/tasks/{uid}", - json={**load_mock(f"v1/tasks/{uid}.json"), "finished": False}, + json={**load_mock_dict(f"v1/tasks/{uid}.json"), "finished": False}, ) # setup diff --git a/tests/posit/connect/test_users.py b/tests/posit/connect/test_users.py index 525ddeba..68adbb3b 100644 --- a/tests/posit/connect/test_users.py +++ b/tests/posit/connect/test_users.py @@ -7,7 +7,7 @@ from posit.connect.client import Client -from .api import load_mock # type: ignore +from .api import load_mock session = Mock() url = Mock() @@ -247,7 +247,7 @@ def test_params(self): json=load_mock("v1/users?page_number=1&page_size=500.jsonc"), ) con = Client(api_key="12345", url="https://connect.example/") - con.users.find_one(key1="value1", key2="value2", key3="value3") + con.users.find_one(key1="value1", key2="value2", key3="value3") # pyright: ignore[reportCallIssue] assert mock_get.call_count == 1 @responses.activate @@ -328,7 +328,7 @@ def test_params(self): json=load_mock("v1/users?page_number=2&page_size=500.jsonc"), ) con = Client(api_key="12345", url="https://connect.example/") - con.users.find_one(key1="value1", key2="value2", key3="value3") + con.users.find_one(key1="value1", key2="value2", key3="value3") # pyright: ignore[reportCallIssue] responses.assert_call_count( "https://connect.example/__api__/v1/users?key1=value1&key2=value2&key3=value3&page_number=1&page_size=500", 1, @@ -340,4 +340,6 @@ def test_params_not_dict_like(self): con = Client(api_key="12345", url="https://connect.example/") not_dict_like = "string" with pytest.raises(TypeError): - con.users.find(not_dict_like) + con.users.find( + not_dict_like # pyright: ignore[reportCallIssue] + ) diff --git a/tests/posit/connect/test_vanities.py b/tests/posit/connect/test_vanities.py index e77bfc54..cfa3dd4a 100644 --- a/tests/posit/connect/test_vanities.py +++ b/tests/posit/connect/test_vanities.py @@ -37,7 +37,13 @@ def test_destroy_calls_after_destroy_callback(self): url = Url(base_url) after_destroy = Mock() params = ResourceParameters(session, url) - vanity = Vanity(params, after_destroy=after_destroy, content_guid=content_guid) + vanity = Vanity( + params, + after_destroy=after_destroy, + content_guid=content_guid, + path=Mock(), + created_time=Mock(), + ) vanity.destroy()