diff --git a/src/posit/connect/client.py b/src/posit/connect/client.py index 412ab0fe..d449716c 100644 --- a/src/posit/connect/client.py +++ b/src/posit/connect/client.py @@ -12,6 +12,7 @@ from .oauth import OAuthIntegration from .content import Content from .users import User, Users +from .visits import Visits class Client: @@ -95,6 +96,10 @@ def content(self) -> Content: """ return Content(config=self.config, session=self.session) + @property + def visits(self) -> Visits: + return Visits(self.config, self.session) + def __del__(self): """Close the session when the Client instance is deleted.""" if hasattr(self, "session") and self.session is not None: diff --git a/src/posit/connect/cursors.py b/src/posit/connect/cursors.py new file mode 100644 index 00000000..fd655efb --- /dev/null +++ b/src/posit/connect/cursors.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +from dataclasses import dataclass, make_dataclass +from typing import Generator, List + +import requests + +# The maximum page size supported by the API. +_MAX_PAGE_SIZE = 500 + + +@dataclass +class CursorPage: + paging: dict + results: List[dict] + + +class CursorPaginator: + def __init__(self, session: requests.Session, url: str, params: dict = {}) -> None: + self.session = session + self.url = url + self.params = params + + def fetch_results(self) -> List[dict]: + """Fetch results. + + Collects all results from all pages. + + Returns + ------- + List[dict] + A coalesced list of all results. + """ + results = [] + for page in self.fetch_pages(): + results.extend(page.results) + return results + + def fetch_pages(self) -> Generator[CursorPage, None, None]: + """Fetch pages. + + Yields + ------ + Generator[Page, None, None] + """ + next = None + while True: + page = self.fetch_page(next) + yield page + cursors: dict = page.paging.get("cursors", {}) + next = cursors.get("next") + if not next: + # stop if a next page is not defined + return + + def fetch_page(self, next: str | None = None) -> CursorPage: + """Fetch a page. + + Parameters + ---------- + next : str | None, optional + the next page identifier or None to fetch the first page, by default None + + Returns + ------- + Page + """ + params = { + **self.params, + "next": next, + "limit": _MAX_PAGE_SIZE, + } + response = self.session.get(self.url, params=params) + return CursorPage(**response.json()) diff --git a/src/posit/connect/visits.py b/src/posit/connect/visits.py new file mode 100644 index 00000000..70b6012f --- /dev/null +++ b/src/posit/connect/visits.py @@ -0,0 +1,240 @@ +from __future__ import annotations + +from typing import List, overload + +from . import urls + +from .cursors import CursorPaginator +from .resources import Resource, Resources + + +class Visit(Resource): + @property + def content_guid(self) -> str: + """The associated unique content identifier. + + Returns + ------- + str + """ + return self["content_guid"] + + @property + def user_guid(self) -> str: + """The associated unique user identifier. + + Returns + ------- + str + """ + return self["user_guid"] + + @property + def rendering_id(self) -> int | None: + """The render id associated with the visit. + + Returns + ------- + int | None + The render id, or None if the associated content type is static. + """ + return self["rendering_id"] + + @property + def bundle_id(self) -> int: + """The bundle id associated with the visit. + + Returns + ------- + int + """ + return self["bundle_id"] + + @property + def variant_key(self) -> str | None: + """The variant key associated with the visit. + + Returns + ------- + str | None + The variant key, or None if the associated content type is static. + """ + return self.get("variant_key") + + @property + def time(self) -> str: + """The visit timestamp. + + Returns + ------- + str + """ + return self["time"] + + @property + def data_version(self) -> int: + """The data version. + + Returns + ------- + int + """ + return self["data_version"] + + @property + def path(self) -> str: + """The path requested by the user. + + Returns + ------- + str + """ + return self["path"] + + +class Visits(Resources): + @overload + def find( + self, + content_guid: str = ..., + min_data_version: int = ..., + start: str = ..., + end: str = ..., + ) -> List[Visit]: + """Find visits. + + Parameters + ---------- + content_guid : str, optional + Filter by an associated unique content identifer, by default ... + min_data_version : int, optional + Filter by a minimum data version, by default ... + start : str, optional + Filter by the start time, by default ... + end : str, optional + Filter by the end time, by default ... + + Returns + ------- + List[Visit] + """ + ... + + @overload + def find(self, *args, **kwargs) -> List[Visit]: + """Find visits. + + Returns + ------- + List[Visit] + """ + ... + + def find(self, *args, **kwargs) -> List[Visit]: + """Find visits. + + Returns + ------- + List[Visit] + """ + params = dict(*args, **kwargs) + params = rename_params(params) + + path = "/v1/instrumentation/content/visits" + url = urls.append_path(self.config.url, path) + paginator = CursorPaginator(self.session, url, params=params) + results = paginator.fetch_results() + return [ + Visit( + config=self.config, + session=self.session, + **result, + ) + for result in results + ] + + @overload + def find_one( + self, + content_guid: str = ..., + min_data_version: int = ..., + start: str = ..., + end: str = ..., + ) -> Visit | None: + """Find a visit. + + Parameters + ---------- + content_guid : str, optional + Filter by an associated unique content identifer, by default ... + min_data_version : int, optional + Filter by a minimum data version, by default ... + start : str, optional + Filter by the start time, by default ... + end : str, optional + Filter by the end time, by default ... + + Returns + ------- + Visit | None + _description_ + """ + ... + + @overload + def find_one(self, *args, **kwargs) -> Visit | None: + """Find a visit. + + Returns + ------- + Visit | None + """ + ... + + def find_one(self, *args, **kwargs) -> Visit | None: + """Find a visit. + + Returns + ------- + Visit | None + """ + params = dict(*args, **kwargs) + params = rename_params(params) + path = "/v1/instrumentation/content/visits" + url = urls.append_path(self.config.url, path) + paginator = CursorPaginator(self.session, url, params=params) + pages = paginator.fetch_pages() + results = (result for page in pages for result in page.results) + visits = ( + Visit( + config=self.config, + session=self.session, + **result, + ) + for result in results + ) + return next(visits, None) + + +def rename_params(params: dict) -> dict: + """Rename params from the internal to the external signature. + + The API accepts `from` as a querystring parameter. Since `from` is a reserved word in Python, the SDK uses the name `start` instead. The querystring parameter `to` takes the same form for consistency. + + Parameters + ---------- + params : dict + + Returns + ------- + dict + """ + if "start" in params: + params["from"] = params["start"] + del params["start"] + + if "end" in params: + params["to"] = params["end"] + del params["end"] + + return params diff --git a/tests/posit/connect/__api__/v1/instrumentation/content/visits?limit=500&next=23948901087.json b/tests/posit/connect/__api__/v1/instrumentation/content/visits?limit=500&next=23948901087.json new file mode 100644 index 00000000..4ba4369a --- /dev/null +++ b/tests/posit/connect/__api__/v1/instrumentation/content/visits?limit=500&next=23948901087.json @@ -0,0 +1,10 @@ +{ + "paging": { + "cursors": { + "previous": "23948901087" + }, + "first": "http://localhost:3443/__api__/v1/instrumentation/content/visits", + "previous": "http://localhost:3443/__api__/v1/instrumentation/content/visits?previous=23948901087" + }, + "results": [] +} diff --git a/tests/posit/connect/__api__/v1/instrumentation/content/visits?limit=500.json b/tests/posit/connect/__api__/v1/instrumentation/content/visits?limit=500.json new file mode 100644 index 00000000..d02537ab --- /dev/null +++ b/tests/posit/connect/__api__/v1/instrumentation/content/visits?limit=500.json @@ -0,0 +1,24 @@ +{ + "paging": { + "cursors": { + "previous": "23948901087", + "next": "23948901087" + }, + "first": "http://localhost:3443/__api__/v1/instrumentation/content/visits", + "previous": "http://localhost:3443/__api__/v1/instrumentation/content/visits?previous=23948901087", + "next": "http://localhost:3443/__api__/v1/instrumentation/content/visits?next=23948901087", + "last": "http://localhost:3443/__api__/v1/instrumentation/content/visits?last=true" + }, + "results": [ + { + "content_guid": "bd1d2285-6c80-49af-8a83-a200effe3cb3", + "user_guid": "08e3a41d-1f8e-47f2-8855-f05ea3b0d4b2", + "variant_key": "HidI2Kwq", + "rendering_id": 7, + "bundle_id": 33, + "time": "2018-09-15T18:00:00-05:00", + "data_version": 1, + "path": "/logs" + } + ] +} diff --git a/tests/posit/connect/test_visits.py b/tests/posit/connect/test_visits.py new file mode 100644 index 00000000..deec9d41 --- /dev/null +++ b/tests/posit/connect/test_visits.py @@ -0,0 +1,146 @@ +import responses + +from responses import matchers + +from posit.connect import Client +from posit.connect.visits import Visit, rename_params + +from .api import load_mock # type: ignore + + +class TestVisitAttributes: + def setup_class(cls): + cls.visit = Visit( + None, + None, + **load_mock("v1/instrumentation/content/visits?limit=500.json")["results"][ + 0 + ], + ) + + def test_content_guid(self): + assert self.visit.content_guid == "bd1d2285-6c80-49af-8a83-a200effe3cb3" + + def test_user_guid(self): + assert self.visit.user_guid == "08e3a41d-1f8e-47f2-8855-f05ea3b0d4b2" + + def test_variant_key(self): + assert self.visit.variant_key == "HidI2Kwq" + + def test_rendering_id(self): + assert self.visit.rendering_id == 7 + + def test_bundle_id(self): + assert self.visit.bundle_id == 33 + + def test_time(self): + assert self.visit.time == "2018-09-15T18:00:00-05:00" + + def test_data_version(self): + assert self.visit.data_version == 1 + + def test_path(self): + assert self.visit.path == "/logs" + + +class TestVisitsFind: + @responses.activate + def test(self): + # behavior + mock_get = [None] * 2 + mock_get[0] = responses.get( + f"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( + f"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, + } + ) + ], + ) + + # setup + c = Client("12345", "https://connect.example") + + # invoke + visits = c.visits.find() + + # assert + assert mock_get[0].call_count == 1 + assert mock_get[1].call_count == 1 + assert len(visits) == 1 + + +class TestVisitsFindOne: + @responses.activate + def test(self): + # behavior + mock_get = [None] * 2 + mock_get[0] = responses.get( + f"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( + f"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, + } + ) + ], + ) + + # setup + c = Client("12345", "https://connect.example") + + # invoke + visit = c.visits.find_one() + + # assert + assert mock_get[0].call_count == 1 + assert mock_get[1].call_count == 0 + assert visit + assert visit.content_guid == "bd1d2285-6c80-49af-8a83-a200effe3cb3" + + +class TestRenameParams: + def test_start_to_from(self): + params = {"start": ...} + params = rename_params(params) + assert "start" not in params + assert "from" in params + + def test_end_to_to(self): + params = {"end": ...} + params = rename_params(params) + assert "end" not in params + assert "to" in params