From 25bab8d02051d02c71aa2b8b26a94f185422103b Mon Sep 17 00:00:00 2001 From: tdstein Date: Thu, 11 Apr 2024 16:28:38 -0400 Subject: [PATCH] feat: adds usage --- src/posit/connect/client.py | 5 + src/posit/connect/content.py | 6 - src/posit/connect/usage.py | 207 ++++++++++++++++++ .../usage?limit=500&next=23948901087.json | 10 + .../shiny/usage?limit=500.json | 21 ++ tests/posit/connect/test_usage.py | 135 ++++++++++++ 6 files changed, 378 insertions(+), 6 deletions(-) create mode 100644 src/posit/connect/usage.py create mode 100644 tests/posit/connect/__api__/v1/instrumentation/shiny/usage?limit=500&next=23948901087.json create mode 100644 tests/posit/connect/__api__/v1/instrumentation/shiny/usage?limit=500.json create mode 100644 tests/posit/connect/test_usage.py diff --git a/src/posit/connect/client.py b/src/posit/connect/client.py index d449716c..e0fc9feb 100644 --- a/src/posit/connect/client.py +++ b/src/posit/connect/client.py @@ -11,6 +11,7 @@ from .config import Config from .oauth import OAuthIntegration from .content import Content +from .usage import Usage from .users import User, Users from .visits import Visits @@ -96,6 +97,10 @@ def content(self) -> Content: """ return Content(config=self.config, session=self.session) + @property + def usage(self) -> Usage: + return Usage(self.config, self.session) + @property def visits(self) -> Visits: return Visits(self.config, self.session) diff --git a/src/posit/connect/content.py b/src/posit/connect/content.py index 896d3e0e..435cc3c5 100644 --- a/src/posit/connect/content.py +++ b/src/posit/connect/content.py @@ -212,12 +212,6 @@ def tags(self) -> List[dict]: # CRUD Methods - @property - def tags(self) -> List[dict]: - return self.get("tags", []) - - # CRUD Methods - def delete(self) -> None: """Delete the content item.""" path = f"v1/content/{self.guid}" diff --git a/src/posit/connect/usage.py b/src/posit/connect/usage.py new file mode 100644 index 00000000..e7af8562 --- /dev/null +++ b/src/posit/connect/usage.py @@ -0,0 +1,207 @@ +from __future__ import annotations + +from typing import List, overload + +from . import urls + +from .cursors import CursorPaginator +from .resources import Resource, Resources + + +class UsageEvent(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 started(self) -> str: + """The started timestamp. + + Returns + ------- + str + """ + return self["started"] + + @property + def ended(self) -> str: + """The ended timestamp. + + Returns + ------- + str + """ + return self["ended"] + + @property + def data_version(self) -> int: + """The data version. + + Returns + ------- + int + """ + return self["data_version"] + + +class Usage(Resources): + @overload + def find( + self, + content_guid: str = ..., + min_data_version: int = ..., + start: str = ..., + end: str = ..., + ) -> List[UsageEvent]: + """Find usage. + + 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[UsageEvent] + """ + ... + + @overload + def find(self, *args, **kwargs) -> List[UsageEvent]: + """Find usage. + + Returns + ------- + List[UsageEvent] + """ + ... + + def find(self, *args, **kwargs) -> List[UsageEvent]: + """Find usage. + + Returns + ------- + List[UsageEvent] + """ + params = dict(*args, **kwargs) + params = rename_params(params) + + path = "/v1/instrumentation/shiny/usage" + url = urls.append_path(self.config.url, path) + paginator = CursorPaginator(self.session, url, params=params) + results = paginator.fetch_results() + return [ + UsageEvent( + 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 = ..., + ) -> UsageEvent | None: + """Find a usage event. + + 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 + ------- + UsageEvent | None + """ + ... + + @overload + def find_one(self, *args, **kwargs) -> UsageEvent | None: + """Find a usage event. + + Returns + ------- + UsageEvent | None + """ + ... + + def find_one(self, *args, **kwargs) -> UsageEvent | None: + """Find a usage event. + + Returns + ------- + UsageEvent | None + """ + params = dict(*args, **kwargs) + params = rename_params(params) + path = "/v1/instrumentation/shiny/usage" + 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 = ( + UsageEvent( + 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/shiny/usage?limit=500&next=23948901087.json b/tests/posit/connect/__api__/v1/instrumentation/shiny/usage?limit=500&next=23948901087.json new file mode 100644 index 00000000..4ba4369a --- /dev/null +++ b/tests/posit/connect/__api__/v1/instrumentation/shiny/usage?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/shiny/usage?limit=500.json b/tests/posit/connect/__api__/v1/instrumentation/shiny/usage?limit=500.json new file mode 100644 index 00000000..ae77dade --- /dev/null +++ b/tests/posit/connect/__api__/v1/instrumentation/shiny/usage?limit=500.json @@ -0,0 +1,21 @@ +{ + "paging": { + "cursors": { + "previous": "23948901087", + "next": "23948901087" + }, + "first": "http://localhost:3443/__api__/v1/instrumentation/shiny/usage", + "previous": "http://localhost:3443/__api__/v1/instrumentation/shiny/usage?previous=23948901087", + "next": "http://localhost:3443/__api__/v1/instrumentation/shiny/usage?next=23948901087", + "last": "http://localhost:3443/__api__/v1/instrumentation/shiny/usage?last=true" + }, + "results": [ + { + "content_guid": "bd1d2285-6c80-49af-8a83-a200effe3cb3", + "user_guid": "08e3a41d-1f8e-47f2-8855-f05ea3b0d4b2", + "started": "2018-09-15T18:00:00-05:00", + "ended": "2018-09-15T18:01:00-05:00", + "data_version": 1 + } + ] +} diff --git a/tests/posit/connect/test_usage.py b/tests/posit/connect/test_usage.py new file mode 100644 index 00000000..76dc0150 --- /dev/null +++ b/tests/posit/connect/test_usage.py @@ -0,0 +1,135 @@ +import responses + +from responses import matchers + +from posit.connect import Client +from posit.connect.usage import UsageEvent, rename_params + +from .api import load_mock # type: ignore + + +class TestUsageEventAttributes: + def setup_class(cls): + cls.event = UsageEvent( + None, + None, + **load_mock("v1/instrumentation/shiny/usage?limit=500.json")["results"][0], + ) + + def test_content_guid(self): + assert self.event.content_guid == "bd1d2285-6c80-49af-8a83-a200effe3cb3" + + def test_user_guid(self): + assert self.event.user_guid == "08e3a41d-1f8e-47f2-8855-f05ea3b0d4b2" + + def test_started(self): + assert self.event.started == "2018-09-15T18:00:00-05:00" + + def test_ended(self): + assert self.event.ended == "2018-09-15T18:01:00-05:00" + + def test_data_version(self): + assert self.event.data_version == 1 + + +class TestUsageFind: + @responses.activate + def test(self): + # behavior + mock_get = [None] * 2 + mock_get[0] = responses.get( + f"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( + f"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 = Client("12345", "https://connect.example") + + # invoke + usage = c.usage.find() + + # assert + assert mock_get[0].call_count == 1 + assert mock_get[1].call_count == 1 + assert len(usage) == 1 + + +class TestUsageFindOne: + @responses.activate + def test(self): + # behavior + mock_get = [None] * 2 + mock_get[0] = responses.get( + f"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( + f"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 = Client("12345", "https://connect.example") + + # invoke + event = c.usage.find_one() + + # assert + assert mock_get[0].call_count == 1 + assert mock_get[1].call_count == 0 + assert event + assert event.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