From 1cce6676488e4ffb3d405bdd2a0781196e6b0902 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 26 Nov 2024 16:43:33 -0500 Subject: [PATCH 01/20] Add/test many tag, tags, and content item tags methods --- integration/tests/posit/connect/test_tags.py | 126 ++++ src/posit/connect/client.py | 24 + src/posit/connect/content.py | 11 + src/posit/connect/tags.py | 607 ++++++++++++++++++ tests/posit/connect/__api__/v1/tags.json | 198 ++++++ tests/posit/connect/__api__/v1/tags/29.json | 7 + tests/posit/connect/__api__/v1/tags/3.json | 7 + .../connect/__api__/v1/tags/3/content.json | 1 + tests/posit/connect/__api__/v1/tags/33.json | 7 + .../connect/__api__/v1/tags/33/content.json | 242 +++++++ .../connect/__api__/v1/tags?name=academy.json | 9 + .../connect/__api__/v1/tags?parent_id=3.json | 51 ++ tests/posit/connect/test_tags.py | 299 +++++++++ 13 files changed, 1589 insertions(+) create mode 100644 integration/tests/posit/connect/test_tags.py create mode 100644 src/posit/connect/tags.py create mode 100644 tests/posit/connect/__api__/v1/tags.json create mode 100644 tests/posit/connect/__api__/v1/tags/29.json create mode 100644 tests/posit/connect/__api__/v1/tags/3.json create mode 100644 tests/posit/connect/__api__/v1/tags/3/content.json create mode 100644 tests/posit/connect/__api__/v1/tags/33.json create mode 100644 tests/posit/connect/__api__/v1/tags/33/content.json create mode 100644 tests/posit/connect/__api__/v1/tags?name=academy.json create mode 100644 tests/posit/connect/__api__/v1/tags?parent_id=3.json create mode 100644 tests/posit/connect/test_tags.py diff --git a/integration/tests/posit/connect/test_tags.py b/integration/tests/posit/connect/test_tags.py new file mode 100644 index 00000000..41cff9b2 --- /dev/null +++ b/integration/tests/posit/connect/test_tags.py @@ -0,0 +1,126 @@ +from typing import TYPE_CHECKING + +import pytest +from packaging import version + +from posit import connect + +from . import CONNECT_VERSION + +if TYPE_CHECKING: + from posit.connect.content import ContentItem + + +# add integration tests here! +@pytest.mark.skipif( + CONNECT_VERSION < version.parse("2024.10.0-dev"), + reason="Packages API unavailable", +) +class TestTags: + @classmethod + def setup_class(cls): + cls.client = connect.Client() + cls.contentA = cls.client.content.create(name=cls.__name__) + cls.contentB = cls.client.content.create(name=cls.__name__) + cls.contentC = cls.client.content.create( + name=cls.__name__, + ) + + @classmethod + def teardown_class(cls): + assert len(cls.client.tags.find()) == 0 + + def test_tags_find(self): + tags = self.client.tags.find() + assert len(tags) == 0 + + def test_tags_create_destroy(self): + tagA = self.client.tags.create(name="tagA") + tagB = self.client.tags.create(name="tagB") + tagC = self.client.tags.create(name="tagC", parent=self.tagA) + + assert len(self.client.tags.find()) == 3 + + tagA.destroy() + tagB.destroy() + ## Deleted when tag A is deleted + # tagC.destroy() + + assert len(self.client.tags.find()) == 0 + + def test_tag_descendants(self): + # Have created tags persist + self.tagA = self.client.tags.create(name="tagA") + self.tagB = self.client.tags.create(name="tagB") + self.tagC = self.client.tags.create(name="tagC", parent=self.tagA) + self.tagD = self.client.tags.create(name="tagD", parent=self.tagC) + + assert self.tagA.descendant_tags.find() == [self.tagC, self.tagD] + + assert len(self.tagB.descendant_tags.find()) == 0 + assert len(self.tagC.descendant_tags.find()) == [self.tagD] + + def test_tag_children(self): + assert self.tagA.children_tags.find() == [self.tagC] + assert self.tagB.children_tags.find() == [] + assert self.tagC.children_tags.find() == [self.tagD] + + def test_tag_parent(self): + assert self.tagA.parent_tag is None + assert self.tagB.parent_tag is None + assert self.tagC.parent_tag == self.tagA + assert self.tagD.parent_tag == self.tagC + + def test_content_a_tags(self): + assert len(self.contentA.tags.find()) == 0 + + self.contentA.tags.add(self.tagA) + self.contentA.tags.add(self.tagB) + + # tagB, tagC, tagA (parent of tagC) + assert len(self.contentA.tags.find()) == 3 + + self.contentA.tags.delete(self.tagB) + assert len(self.contentA.tags.find()) == 2 + + # Removes tagC and tagA (parent of tagC) + self.contentA.tags.delete(self.tagA) + assert len(self.contentA.tags.find()) == 0 + + def test_tags_content_items(self): + assert len(self.tagA.content_items.find()) == 0 + assert len(self.tagB.content_items.find()) == 0 + assert len(self.tagC.content_items.find()) == 0 + + self.contentA.tags.add(self.tagA) + self.contentA.tags.add(self.tagB) + + self.contentB.tags.add(self.tagA) + self.contentB.tags.add(self.tagC) + + self.contentC.tags.add(self.tagC) + + assert len(self.contentA.tags.find()) == 2 + assert len(self.contentB.tags.find()) == 2 + assert len(self.contentC.tags.find()) == 1 + + assert len(self.tagA.content_items.find()) == 2 + assert len(self.tagB.content_items.find()) == 1 + assert len(self.tagC.content_items.find()) == 2 + + # Make sure unique content items are found + content_items_list: list[ContentItem] = [] + for tag in [self.tagA, *self.tagA.descendant_tags.find()]: + content_items_list.extend(tag.content_items.find()) + # Get unique items + content_items_list = list(set(content_items_list)) + + assert content_items_list == [self.contentA, self.contentB, self.contentC] + + self.contentA.tags.delete(self.tagA, self.tagB) + self.contentB.tags.delete(self.tagA) + self.contentC.tags.delete(self.tagC) + + assert len(self.tagA.content_items.find()) == 0 + assert len(self.tagB.content_items.find()) == 0 + assert len(self.tagC.content_items.find()) == 0 diff --git a/src/posit/connect/client.py b/src/posit/connect/client.py index e1ba808c..12d2daee 100644 --- a/src/posit/connect/client.py +++ b/src/posit/connect/client.py @@ -6,6 +6,8 @@ from requests import Response, Session +from posit.connect.tags import Tags + from . import hooks, me from .auth import Auth from .config import Config @@ -229,6 +231,28 @@ def content(self) -> Content: """ return Content(self.resource_params) + @property + def tags(self) -> Tags: + """ + The tags resource interface. + + Returns + ------- + Tags + The tags resource instance. + + Examples + -------- + ```python + import posit + + client = posit.connect.Client() + + tags = client.tags.find() + ``` + """ + return Tags(self._ctx, "v1/tags") + @property def metrics(self) -> Metrics: """ diff --git a/src/posit/connect/content.py b/src/posit/connect/content.py index 404867b7..1a3507f7 100644 --- a/src/posit/connect/content.py +++ b/src/posit/connect/content.py @@ -28,6 +28,7 @@ from .packages import ContentPackagesMixin as PackagesMixin from .permissions import Permissions from .resources import Resource, ResourceParameters, Resources +from .tags import ContentItemTags from .vanities import VanityMixin from .variants import Variants @@ -496,6 +497,16 @@ def is_rendered(self) -> bool: "quarto-static", } + @property + def tags(self) -> ContentItemTags: + path = f"v1/content/{self['guid']}/tags" + return ContentItemTags( + self._ctx, + path, + tags_path="v1/tags", + content_guid=self["guid"], + ) + class Content(Resources): """Content resource. diff --git a/src/posit/connect/tags.py b/src/posit/connect/tags.py new file mode 100644 index 00000000..2cb4ae79 --- /dev/null +++ b/src/posit/connect/tags.py @@ -0,0 +1,607 @@ +# ## Return all tags +# client.tags.find() -> list[Tag] + +# ## Return all tags with name and parent +# client.tags.find(name="tag_name", parent="parent_tag_guid" | parent_tag | None) + +# # Create Tag +# mytag = client.tags.create( +# name="tag_name", +# parent="parent_tag_guid" | parent_tag | None +# ) -> Tag + +# # Delete Tag + +# mytag = client.tags.get("tag_guid") +# mytag.destroy() -> None + + +# # Find content using tags +# mycontentitems = mytag.content.find() -> list[ContentItem] + +# # Get content item's tags +# mycontentitem: ContentItem = mycontentitems[0] +# mycontentitem.tags.find() -> list[Tag] +from __future__ import annotations + +from typing import TYPE_CHECKING, Optional + +from typing_extensions import NotRequired, TypedDict, Unpack + +from .context import Context, ContextManager +from .resources import Active, ResourceParameters + +if TYPE_CHECKING: + from .content import ContentItem + + +class Tag(Active): + """Tag resource.""" + + class _Attrs(TypedDict, total=False): + id: str + """The identifier for the tag.""" + name: str + """The name of the tag.""" + parent_id: NotRequired[Optional[str]] + """The identifier for the parent tag. If there is no parent_id, the tag is a top-level tag.""" + created_time: str + """The timestamp (RFC3339) indicating when the tag was created. Ex. '2006-01-02T15:04:05Z'""" + updated_time: str + """The timestamp (RFC3339) indicating when the tag was created. Ex. '2006-01-02T15:04:05Z'""" + + def __init__(self, ctx: Context, path: str, /, **kwargs: Unpack[Tag._Attrs]): + super().__init__(ctx, path, **kwargs) + + @property + def parent_tag(self) -> Tag | None: + if self.get("parent_id", None) is None: + return None + + # TODO-barret-future: Replace with `self._ctx.client.tags.get(self["parent_id"])` + path = "v1/tags/" + self["parent_id"] + url = self._ctx.url + path + response = self._ctx.session.get(url) + return Tag(self._ctx, path, **response.json()) + + @property + def children_tags(self) -> ChildrenTags: + """ + Find all child tags that are direct children of this tag. + + Returns + ------- + ChildrenTags + Helper class that can `.find()` the child tags. + + Examples + -------- + ```python + import posit + + client = posit.connect.Client(...) + + mytag = client.tags.find(id="TAG_ID_HERE") + children = mytag.children_tags().find() + ``` + """ + return ChildrenTags(self._ctx, self._path, parent_tag=self) + + # TODO-barret-Q: Should this be `.descendant_tags` or `.descendants`? + # TODO-barret-Q: Should this be `.find_descendants() -> list[Tag]`? + @property + def descendant_tags(self) -> DescendantTags: + """ + Find all tags that descend from this tag. + + Returns + ------- + DescendantTags + Helper class that can `.find()` all descendant tags. + + Examples + -------- + ```python + import posit + + client = posit.connect.Client(...) + + mytag = client.tags.find(id="TAG_ID_HERE") + descendant_tags = mytag.descendant_tags().find() + ``` + """ + return DescendantTags(self._ctx, parent_tag=self) + + @property + def content_items(self) -> TagContentItems: + """ + Find all content items using this tag. + + Returns + ------- + TagContentItems + Helper class that can `.find()` all content items. + + Examples + -------- + ```python + import posit + + client = posit.connect.Client(...) + first_tag = client.tags.find()[0] + first_tag_content_items = first_tag.content_items.find() + ``` + """ + path = self._path + "/content" + return TagContentItems(self._ctx, path) + + def destroy(self) -> None: + """ + Removes the tag. + + Deletes a tag, including all descendants in its own tag hierarchy. + """ + url = self._ctx.url + self._path + self._ctx.session.delete(url) + + +class TagContentItems(ContextManager): + def __init__(self, ctx: Context, path: str) -> None: + super().__init__() + self._ctx = ctx + self._path = path + + def find(self) -> list[ContentItem]: + """ + Find all content items that are tagged with this tag. + + Returns + ------- + list[ContentItem] + List of content items that are tagged with this tag. + """ + from .content import ContentItem + + url = self._ctx.url + self._path + response = self._ctx.session.get(url) + results = response.json() + params = ResourceParameters(self._ctx.session, self._ctx.url) + print(results) + return [ContentItem(params, **result) for result in results] + + +class ChildrenTags(ContextManager): + def __init__(self, ctx: Context, path: str, /, *, parent_tag: Tag) -> None: + super().__init__() + self._ctx = ctx + self._path = path + self._parent_tag = parent_tag + + def find(self) -> list[Tag]: + """ + Find all child tags that are direct children of a single tag. + + Returns + ------- + list[Tag] + List of child tags. (Does not include the parent tag.) + """ + # TODO-future-barret; + # This method could be done with `self._ctx.client.tags.find(parent=self)` + # For now, use DescendantTags and filter the results + descendant_tags = DescendantTags(self._ctx, parent_tag=self._parent_tag).find() + + # Filter out tags that are not direct children + child_tags: list[Tag] = [] + for tag in descendant_tags: + if tag.get("parent_id") == self._parent_tag["id"]: + child_tags.append(tag) + + return child_tags + + +# def _unique_content_items_for_tags(tags: list[Tag]) -> list[ContentItem]: +# tag_content_items: list[ContentItem] = [] +# content_item_seen = set[str]() +# for descendant_tag in tags: +# for content_item in descendant_tag.content_items.find(): +# if content_item["guid"] not in content_item_seen: +# tag_content_items.append(content_item) +# content_item_seen.add(content_item["guid"]) +# return tag_content_items + + +# class ChildrenTagsContentItems(ContextManager): +# def __init__(self, ctx: Context, path: str, /, *, parent_tag: Tag) -> None: +# super().__init__() +# self._ctx = ctx +# self._path = path +# self._parent_tag = parent_tag + +# def find(self) -> list[ContentItem]: +# """ +# Find all unique content items that are tagged with a tag or any of the tag's descendants. + +# Returns +# ------- +# list[ContentItem] +# List of content items that contain a tag or any of its descendant tags. +# """ +# descendant_tags = ChildrenTags( +# self._ctx, +# self._path, +# parent_tag=self._parent_tag, +# ).find() +# return _unique_content_items_for_tags([self._parent_tag, *descendant_tags]) + + +# class DescendantTagsContentItems(ContextManager): +# def __init__(self, ctx: Context, path: str, /, *, parent_tag: Tag) -> None: +# super().__init__() +# self._ctx = ctx +# self._path = path +# self._parent_tag = parent_tag + +# def find(self) -> list[ContentItem]: +# """ +# Find all unique content items that are tagged with a tag or any of the tag's descendants. + +# Returns +# ------- +# list[ContentItem] +# List of content items that contain a tag or any of its descendant tags. +# """ +# descendant_tags = DescendantTags(self._ctx, parent_tag=self._parent_tag).find() +# return _unique_content_items_for_tags([self._parent_tag, *descendant_tags]) + + +class DescendantTags(ContextManager): + def __init__(self, ctx: Context, /, *, parent_tag: Tag) -> None: + super().__init__() + self._ctx = ctx + self._path = "v1/tags" + self._parent_tag = parent_tag + + # @property + # def content_items(self) -> DescendantTagsContentItems: + # """ + # Find all unique content items that are tagged with a tag or any of the tag's descendants. + + # Returns + # ------- + # DescendantTagsContentItems + # Helper class that can `.find()` all content items that contain a tag or any of its + # descendant tags. + # """ + # return DescendantTagsContentItems(self._ctx, self._path, parent_tag=self._parent_tag) + + def find(self) -> list[Tag]: + """ + Find all child tags that descend from a single tag. + + Returns + ------- + list[Tag] + List of tags that desc + """ + # This method could be done with `tags.find(parent=self._root_id)` but it would require + # a request for every child tag recursively. + # By using the `/v1/tags` endpoint, we can get all tags in a single request + # and filter them in Python. + + # TODO-barret-future: Replace with `self._ctx.client.tags.find(parent=self._root_id)` + url = self._ctx.url + self._path + response = self._ctx.session.get(url) + results = response.json() + all_tags = [] + for result in results: + tag = Tag(self._ctx, f"{self._path}/{result['id']}", **result) + all_tags.append(tag) + + # all_tags = [ + # Tag( + # self._ctx, + # # TODO-barret-future: Replace with `self._ctx.client.tags._path`? + # f"{self._path}/{results['id']}", + # **result, + # ) + # for result in results + # ] + + # O(n^2) algorithm to find all child tags + # This could be optimized by using a dictionary to store the tags by their parent_id and + # then recursively traverse the dictionary to find all child tags. O(2 * n) = O(n) but the + # code is more complex + # + # If the tags are always ordered, it could be performed in a single pass (O(n)) as parents + # always appear before any children + child_tags = [] + parent_ids = {self._parent_tag["id"]} + child_tag_found: bool = True + while child_tag_found: + child_tag_found = False + + for tag in [*all_tags]: + if tag.get("parent_id") in parent_ids: + child_tags.append(tag) + parent_ids.add(tag["id"]) + all_tags.remove(tag) + child_tag_found = True + + return child_tags + + # O(n) algorithm to find all child tags + child_tags = [] + parent_ids = {self._root_id} + + # Construct a d + # O(n) + tag_by_parent_id: dict[str, list[Tag]] = {} + for tag in all_tags: + parent_id: str | None = tag.get("parent_id", None) + if parent_id is None: + continue + parent_id = str(parent_id) + if parent_id not in tag_by_parent_id: + tag_by_parent_id[parent_id] = [] + tag_by_parent_id[parent_id].append(tag) + + # O(n) compute space + ret: list[Tag] = [] + parent_ids_seen = set[str]() + while len(parent_ids) > 0: + parent_id = parent_ids.pop() + if parent_id in parent_ids_seen: + continue + parent_ids_seen.add(parent_id) + if parent_id in tag_by_parent_id: + tags = tag_by_parent_id[parent_id] + ret.extend(tags) + for tag in tags: + parent_ids.add(tag["id"]) + + return ret + + +class Tags(ContextManager): + """Content item tags resource.""" + + def __init__(self, ctx: Context, path: str) -> None: + super().__init__() + self._ctx = ctx + self._path = path + + def get(self, tag_id: str) -> Tag: + """ + Get a single tag by its identifier. + + Parameters + ---------- + tag_id : str + The identifier for the tag. + + Returns + ------- + Tag + The tag object. + """ + # TODO-barret-future: Replace with `self._ctx.client.tags.find(id=tag_id)` + assert isinstance(tag_id, str), "Tag `id` must be a string" + assert tag_id != "", "Tag `id` cannot be an empty string" + path = f"{self._path}/{tag_id}" + url = self._ctx.url + path + response = self._ctx.session.get(url) + return Tag(self._ctx, path, **response.json()) + + class _NameParentAttrs(TypedDict, total=False): + name: str + """ + The name of the tag. + + Note: tag names are only unique within the scope of a parent, which + means that it is possible to have multiple results when querying by + name; however, querying by both `name` and `parent` ensures a single + result. + """ + parent: NotRequired[str | Tag | None] + """The identifier for the parent tag. If there is no parent, the tag is a top-level tag.""" + + def _update_parent_kwargs(self, kwargs: dict) -> dict: + parent = kwargs.get("parent", None) + if parent is None: + return kwargs + + ret_kwargs = {**kwargs} + + # Remove `parent` from ret_kwargs + # and store the `parent_id` in the ret_kwargs below + del ret_kwargs["parent"] + + if isinstance(parent, Tag): + parent: str = parent["id"] + + if isinstance(parent, str): + assert parent != "", "Tag `parent` cannot be an empty string" + ret_kwargs["parent_id"] = parent + return ret_kwargs + + raise TypeError("`parent=` must be a string or Tag instance") + + def find(self, /, **kwargs: Unpack[Tags._NameParentAttrs]) -> list[Tag]: + """ + Find tags by name and/or parent. + + Note: tag names are only unique within the scope of a parent, which means that it is + possible to have multiple results when querying by name; However, querying by both `name` + and `parent` ensures a single result. + + Parameters + ---------- + name : str, optional + The name of the tag. + parent : str, Tag, optional + The identifier for the parent tag. If there is no parent, the tag is a top-level tag. + + Returns + ------- + list[Tag] + List of tags that match the query. Defaults to all Tags. + + Examples + -------- + ```python + import posit + + client = posit.connect.Client(...) + + # Find all tags + all_tags = client.tags.find() + + # Find all tags with the name + mytag = client.tags.find(name="tag_name") + + # Find all tags with the name and parent + subtags = client.tags.find(name="sub_name", parent=mytag) + subtags = client.tags.find(name="sub_name", parent=mytag["id"]) + ``` + """ + updated_kwargs = self._update_parent_kwargs( + kwargs, # pyright: ignore[reportArgumentType] + ) + url = self._ctx.url + self._path + + response = self._ctx.session.get(url, params=updated_kwargs) + results = response.json() + return [Tag(self._ctx, f"{self._path}/{result['id']}", **result) for result in results] + + def create(self, /, **kwargs: Unpack[Tags._NameParentAttrs]) -> Tag: + """ + Create a tag. + + Parameters + ---------- + name : str + The name of the tag. + parent : str, Tag, optional + The identifier for the parent tag. If there is no parent, the tag is a top-level tag. + + Returns + ------- + Tag + Newly created tag object. + + Examples + -------- + ```python + import posit + + client = posit.connect.Client(...) + + mytag = client.tags.create(name="tag_name") + subtag = client.tags.create(name="subtag_name", parent=mytag) + ``` + """ + updated_kwargs = self._update_parent_kwargs( + kwargs, # pyright: ignore[reportArgumentType] + ) + + url = self._ctx.url + self._path + response = self._ctx.session.post(url, json=updated_kwargs) + result = response.json() + return Tag(self._ctx, f"{self._path}/{result['id']}", **result) + + +class ContentItemTags(ContextManager): + """Content item tags resource.""" + + def __init__(self, ctx: Context, path: str, /, *, tags_path: str, content_guid: str) -> None: + super().__init__() + self._ctx = ctx + + # v1/content/{guid}/tags + self._path = path + self._tags_path = tags_path + + self._content_guid = content_guid + + def find(self) -> list[Tag]: + """ + Find all tags that are associated with a single content item. + + Returns + ------- + list[Tag] + List of tags associated with the content item. + """ + url = self._ctx.url + self._path + response = self._ctx.session.get(url) + results = response.json() + + tags: list[Tag] = [] + for result in results: + tags.append( + Tag( + self._ctx, + f"{self._tags_path}/{result['id']}", + **result, + ) + ) + + return tags + + def _to_tag_ids(self, tags: tuple[str | Tag, ...]) -> list[str]: + tag_ids: list[str] = [] + for i, tag in enumerate(tags): + tag_id = tag["id"] if isinstance(tag, Tag) else tag + assert isinstance(tag_id, str), f"Expected 'tags[{i}]' to be a string. Got: {tag_id}" + assert len(tag_id) > 0, "Expected 'tags[{i}]' value to be non-empty" + + tag_ids.append(tag_id) + + return tag_ids + + def add(self, *tags: str | Tag) -> None: + """ + Add the specified tags to an individual content item. + + When adding a tag, all tags above the specified tag in the tag tree are also added to the + content item. + + Parameters + ---------- + tags : str | Tag + The tags id or tag object to add to the content item. + + Returns + ------- + None + """ + tag_ids = self._to_tag_ids(tags) + + url = self._ctx.url + self._path + for tag_id in tag_ids: + _ = self._ctx.session.post(url, json={"tag_id": tag_id}) + return + + def delete(self, *tags: str | Tag) -> None: + """ + Remove the specified tags from an individual content item. + + When removing a tag, all tags above the specified tag in the tag tree are also removed from + the content item. + + Parameters + ---------- + tags : str | Tag + The tags id or tag object to remove from the content item. + + Returns + ------- + None + """ + tag_ids = self._to_tag_ids(tags) + + url = self._ctx.url + self._path + for tag_id in tag_ids: + _ = self._ctx.session.delete(url, json={"tag_id": tag_id}) + return diff --git a/tests/posit/connect/__api__/v1/tags.json b/tests/posit/connect/__api__/v1/tags.json new file mode 100644 index 00000000..01a44a5d --- /dev/null +++ b/tests/posit/connect/__api__/v1/tags.json @@ -0,0 +1,198 @@ +[ + { + "id": "3", + "name": "Internal Solutions", + "parent_id": null, + "created_time": "2019-10-08T19:44:49Z", + "updated_time": "2019-10-08T19:44:49Z" + }, + { + "id": "5", + "name": "Life Cycle", + "parent_id": null, + "created_time": "2019-10-08T19:45:21Z", + "updated_time": "2019-10-08T19:45:21Z" + }, + { + "id": "6", + "name": "Dev", + "parent_id": "5", + "created_time": "2019-10-08T19:45:22Z", + "updated_time": "2019-10-08T19:45:22Z" + }, + { + "id": "7", + "name": "Prod", + "parent_id": "5", + "created_time": "2019-10-08T19:45:23Z", + "updated_time": "2019-10-08T19:45:23Z" + }, + { + "id": "10", + "name": "Sales", + "parent_id": null, + "created_time": "2020-02-19T23:41:40Z", + "updated_time": "2020-02-19T23:41:40Z" + }, + { + "id": "11", + "name": "Tools", + "parent_id": "10", + "created_time": "2020-02-19T23:41:49Z", + "updated_time": "2020-02-19T23:41:49Z" + }, + { + "id": "12", + "name": "diagnostics", + "parent_id": "3", + "created_time": "2020-04-30T21:30:22Z", + "updated_time": "2020-04-30T21:30:22Z" + }, + { + "id": "13", + "name": "white-glove", + "parent_id": "3", + "created_time": "2020-05-22T13:04:35Z", + "updated_time": "2020-05-22T13:04:35Z" + }, + { + "id": "14", + "name": "sol-eng", + "parent_id": "3", + "created_time": "2020-06-22T16:52:18Z", + "updated_time": "2020-06-22T16:52:18Z" + }, + { + "id": "15", + "name": "colorado", + "parent_id": "14", + "created_time": "2020-06-22T16:52:29Z", + "updated_time": "2020-06-22T16:52:29Z" + }, + { + "id": "16", + "name": "training-tool", + "parent_id": "14", + "created_time": "2020-06-22T16:52:32Z", + "updated_time": "2020-06-22T16:52:32Z" + }, + { + "id": "17", + "name": "dashboard", + "parent_id": "14", + "created_time": "2020-06-22T16:52:39Z", + "updated_time": "2020-06-22T16:52:39Z" + }, + { + "id": "18", + "name": "small-groups", + "parent_id": "14", + "created_time": "2020-06-22T16:53:28Z", + "updated_time": "2020-06-22T16:53:28Z" + }, + { + "id": "19", + "name": "docs", + "parent_id": "14", + "created_time": "2020-06-22T17:19:52Z", + "updated_time": "2020-06-22T17:19:52Z" + }, + { + "id": "20", + "name": "quickstart", + "parent_id": "14", + "created_time": "2020-06-22T17:21:21Z", + "updated_time": "2020-06-22T17:21:21Z" + }, + { + "id": "21", + "name": "ops", + "parent_id": "14", + "created_time": "2020-06-22T17:21:41Z", + "updated_time": "2020-06-22T17:21:41Z" + }, + { + "id": "23", + "name": "productivity", + "parent_id": "14", + "created_time": "2020-07-09T01:54:59Z", + "updated_time": "2020-07-09T01:54:59Z" + }, + { + "id": "24", + "name": "slack", + "parent_id": "23", + "created_time": "2020-07-09T01:55:15Z", + "updated_time": "2020-07-09T01:55:15Z" + }, + { + "id": "25", + "name": "scheduler", + "parent_id": "14", + "created_time": "2020-07-10T13:48:20Z", + "updated_time": "2020-07-10T13:48:20Z" + }, + { + "id": "26", + "name": "calendar", + "parent_id": "17", + "created_time": "2020-07-17T11:02:15Z", + "updated_time": "2020-07-17T11:02:15Z" + }, + { + "id": "27", + "name": "data", + "parent_id": "26", + "created_time": "2020-07-17T11:02:20Z", + "updated_time": "2020-07-17T11:02:20Z" + }, + { + "id": "28", + "name": "data_raw_quarter", + "parent_id": "26", + "created_time": "2020-07-17T11:02:23Z", + "updated_time": "2020-07-17T11:02:31Z" + }, + { + "id": "29", + "name": "product management", + "parent_id": "3", + "created_time": "2020-08-17T20:16:24Z", + "updated_time": "2020-08-17T20:16:24Z" + }, + { + "id": "30", + "name": "rsc-state-of-market", + "parent_id": "29", + "created_time": "2020-08-17T20:16:37Z", + "updated_time": "2020-08-17T20:16:37Z" + }, + { + "id": "31", + "name": "RSC", + "parent_id": "3", + "created_time": "2020-08-17T20:16:45Z", + "updated_time": "2020-08-17T20:16:45Z" + }, + { + "id": "32", + "name": "Automation", + "parent_id": "10", + "created_time": "2021-06-09T01:16:53Z", + "updated_time": "2021-06-09T01:16:53Z" + }, + { + "id": "33", + "name": "academy", + "parent_id": "3", + "created_time": "2021-10-18T18:37:56Z", + "updated_time": "2021-10-18T18:37:56Z" + }, + { + "id": "34", + "name": "Support", + "parent_id": "3", + "created_time": "2023-05-18T16:41:59Z", + "updated_time": "2023-05-18T16:41:59Z" + } +] diff --git a/tests/posit/connect/__api__/v1/tags/29.json b/tests/posit/connect/__api__/v1/tags/29.json new file mode 100644 index 00000000..9fa34c6c --- /dev/null +++ b/tests/posit/connect/__api__/v1/tags/29.json @@ -0,0 +1,7 @@ +{ + "id": "29", + "name": "product management", + "parent_id": "3", + "created_time": "2020-08-17T20:16:24Z", + "updated_time": "2020-08-17T20:16:24Z" +} diff --git a/tests/posit/connect/__api__/v1/tags/3.json b/tests/posit/connect/__api__/v1/tags/3.json new file mode 100644 index 00000000..8d60694b --- /dev/null +++ b/tests/posit/connect/__api__/v1/tags/3.json @@ -0,0 +1,7 @@ +{ + "id": "3", + "name": "Internal Solutions", + "parent_id": null, + "created_time": "2019-10-08T19:44:49Z", + "updated_time": "2019-10-08T19:44:49Z" +} diff --git a/tests/posit/connect/__api__/v1/tags/3/content.json b/tests/posit/connect/__api__/v1/tags/3/content.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/tests/posit/connect/__api__/v1/tags/3/content.json @@ -0,0 +1 @@ +[] diff --git a/tests/posit/connect/__api__/v1/tags/33.json b/tests/posit/connect/__api__/v1/tags/33.json new file mode 100644 index 00000000..ce330bf3 --- /dev/null +++ b/tests/posit/connect/__api__/v1/tags/33.json @@ -0,0 +1,7 @@ +{ + "id": "33", + "name": "academy", + "parent_id": "3", + "created_time": "2021-10-18T18:37:56Z", + "updated_time": "2021-10-18T18:37:56Z" +} diff --git a/tests/posit/connect/__api__/v1/tags/33/content.json b/tests/posit/connect/__api__/v1/tags/33/content.json new file mode 100644 index 00000000..093aa81f --- /dev/null +++ b/tests/posit/connect/__api__/v1/tags/33/content.json @@ -0,0 +1,242 @@ +[ + { + "guid": "cb0bcb8b-410a-41a2-91a3-187bc895229a", + "name": "education-usersnap", + "title": "education-usersnap", + "description": "", + "access_type": "logged_in", + "locked": false, + "locked_message": "", + "extension": false, + "connection_timeout": null, + "read_timeout": null, + "init_timeout": null, + "idle_timeout": null, + "max_processes": null, + "min_processes": null, + "max_conns_per_process": null, + "load_factor": null, + "memory_request": null, + "memory_limit": null, + "cpu_request": null, + "cpu_limit": null, + "amd_gpu_limit": null, + "nvidia_gpu_limit": null, + "service_account_name": null, + "default_image_name": null, + "created_time": "2023-05-16T22:25:46Z", + "last_deployed_time": "2023-06-18T18:08:53Z", + "bundle_id": "378731", + "app_mode": "rmd-static", + "content_category": "", + "parameterized": false, + "cluster_name": "Local", + "image_name": null, + "r_version": "4.2.3", + "py_version": null, + "quarto_version": null, + "r_environment_management": true, + "default_r_environment_management": null, + "py_environment_management": null, + "default_py_environment_management": null, + "run_as": null, + "run_as_current_user": false, + "owner_guid": "463300e5-cf4d-40ce-a0ce-c2e1b83b13e6", + "content_url": "https://connect.posit.it/content/cb0bcb8b-410a-41a2-91a3-187bc895229a/", + "dashboard_url": "https://connect.posit.it/connect/#/apps/cb0bcb8b-410a-41a2-91a3-187bc895229a", + "app_role": "viewer", + "id": "9850" + }, + { + "guid": "56d31acd-94b8-46e4-86c7-139e7ef6275f", + "name": "-gradecode--Documentation-1660329537647", + "title": "{gradecode} Documentation", + "description": "", + "access_type": "logged_in", + "locked": false, + "locked_message": "", + "extension": false, + "connection_timeout": null, + "read_timeout": null, + "init_timeout": null, + "idle_timeout": null, + "max_processes": null, + "min_processes": null, + "max_conns_per_process": null, + "load_factor": null, + "memory_request": null, + "memory_limit": null, + "cpu_request": null, + "cpu_limit": null, + "amd_gpu_limit": null, + "nvidia_gpu_limit": null, + "service_account_name": null, + "default_image_name": null, + "created_time": "2022-08-12T18:38:57Z", + "last_deployed_time": "2023-04-14T00:16:20Z", + "bundle_id": "347076", + "app_mode": "static", + "content_category": "site", + "parameterized": false, + "cluster_name": null, + "image_name": null, + "r_version": null, + "py_version": null, + "quarto_version": null, + "r_environment_management": null, + "default_r_environment_management": null, + "py_environment_management": null, + "default_py_environment_management": null, + "run_as": null, + "run_as_current_user": false, + "owner_guid": "e5503de2-35ce-4c8b-9c11-a5662bfc68c9", + "content_url": "https://connect.posit.it/content/56d31acd-94b8-46e4-86c7-139e7ef6275f/", + "dashboard_url": "https://connect.posit.it/connect/#/apps/56d31acd-94b8-46e4-86c7-139e7ef6275f", + "app_role": "viewer", + "id": "8958" + }, + { + "guid": "6a238283-b684-48ee-a820-1c7289138806", + "name": "academy-writers-guide", + "title": "Academy Writer's Guide", + "description": "", + "access_type": "logged_in", + "locked": false, + "locked_message": "", + "extension": false, + "connection_timeout": null, + "read_timeout": null, + "init_timeout": null, + "idle_timeout": null, + "max_processes": null, + "min_processes": null, + "max_conns_per_process": null, + "load_factor": null, + "memory_request": null, + "memory_limit": null, + "cpu_request": null, + "cpu_limit": null, + "amd_gpu_limit": null, + "nvidia_gpu_limit": null, + "service_account_name": null, + "default_image_name": null, + "created_time": "2022-01-10T19:03:21Z", + "last_deployed_time": "2023-03-24T17:37:06Z", + "bundle_id": "325822", + "app_mode": "static", + "content_category": "site", + "parameterized": false, + "cluster_name": null, + "image_name": null, + "r_version": null, + "py_version": null, + "quarto_version": null, + "r_environment_management": null, + "default_r_environment_management": null, + "py_environment_management": null, + "default_py_environment_management": null, + "run_as": null, + "run_as_current_user": false, + "owner_guid": "aef7df1a-216b-49c9-b971-2ea163b7b7ff", + "content_url": "https://connect.posit.it/content/6a238283-b684-48ee-a820-1c7289138806/", + "dashboard_url": "https://connect.posit.it/connect/#/apps/6a238283-b684-48ee-a820-1c7289138806", + "app_role": "viewer", + "id": "8404" + }, + { + "guid": "b64ab2d4-c7b6-4444-848a-cf66c70c4a54", + "name": "pfe2e", + "title": "p4e2e", + "description": "RStudio internal reference", + "access_type": "logged_in", + "locked": false, + "locked_message": "", + "extension": false, + "connection_timeout": null, + "read_timeout": null, + "init_timeout": null, + "idle_timeout": null, + "max_processes": null, + "min_processes": null, + "max_conns_per_process": null, + "load_factor": null, + "memory_request": null, + "memory_limit": null, + "cpu_request": null, + "cpu_limit": null, + "amd_gpu_limit": null, + "nvidia_gpu_limit": null, + "service_account_name": null, + "default_image_name": null, + "created_time": "2022-07-11T16:29:54Z", + "last_deployed_time": "2022-09-02T23:56:33Z", + "bundle_id": "255607", + "app_mode": "static", + "content_category": "site", + "parameterized": false, + "cluster_name": null, + "image_name": null, + "r_version": null, + "py_version": null, + "quarto_version": null, + "r_environment_management": null, + "default_r_environment_management": null, + "py_environment_management": null, + "default_py_environment_management": null, + "run_as": null, + "run_as_current_user": false, + "owner_guid": "885e7f8e-bedf-4672-961f-487fbd67479c", + "content_url": "https://connect.posit.it/content/b64ab2d4-c7b6-4444-848a-cf66c70c4a54/", + "dashboard_url": "https://connect.posit.it/connect/#/apps/b64ab2d4-c7b6-4444-848a-cf66c70c4a54", + "app_role": "viewer", + "id": "8838" + }, + { + "guid": "69000ef5-b013-4b30-bbb5-94f0e76c6a86", + "name": "learnr-tutorial-with-help", + "title": "learnr-tutorial-with-help", + "description": "", + "access_type": "logged_in", + "locked": false, + "locked_message": "", + "extension": false, + "connection_timeout": null, + "read_timeout": null, + "init_timeout": null, + "idle_timeout": null, + "max_processes": null, + "min_processes": null, + "max_conns_per_process": null, + "load_factor": null, + "memory_request": null, + "memory_limit": null, + "cpu_request": null, + "cpu_limit": null, + "amd_gpu_limit": null, + "nvidia_gpu_limit": null, + "service_account_name": null, + "default_image_name": null, + "created_time": "2020-11-23T15:24:27Z", + "last_deployed_time": "2020-11-23T15:32:41Z", + "bundle_id": "33418", + "app_mode": "rmd-shiny", + "content_category": "", + "parameterized": false, + "cluster_name": null, + "image_name": null, + "r_version": "4.0.2", + "py_version": null, + "quarto_version": null, + "r_environment_management": true, + "default_r_environment_management": null, + "py_environment_management": null, + "default_py_environment_management": null, + "run_as": null, + "run_as_current_user": false, + "owner_guid": "aef7df1a-216b-49c9-b971-2ea163b7b7ff", + "content_url": "https://connect.posit.it/content/69000ef5-b013-4b30-bbb5-94f0e76c6a86/", + "dashboard_url": "https://connect.posit.it/connect/#/apps/69000ef5-b013-4b30-bbb5-94f0e76c6a86", + "app_role": "viewer", + "id": "1426" + } +] diff --git a/tests/posit/connect/__api__/v1/tags?name=academy.json b/tests/posit/connect/__api__/v1/tags?name=academy.json new file mode 100644 index 00000000..8cfd7bba --- /dev/null +++ b/tests/posit/connect/__api__/v1/tags?name=academy.json @@ -0,0 +1,9 @@ +[ + { + "id": "33", + "name": "academy", + "parent_id": "3", + "created_time": "2021-10-18T18:37:56Z", + "updated_time": "2021-10-18T18:37:56Z" + } +] diff --git a/tests/posit/connect/__api__/v1/tags?parent_id=3.json b/tests/posit/connect/__api__/v1/tags?parent_id=3.json new file mode 100644 index 00000000..9d5c12a6 --- /dev/null +++ b/tests/posit/connect/__api__/v1/tags?parent_id=3.json @@ -0,0 +1,51 @@ +[ + { + "id": "12", + "name": "diagnostics", + "parent_id": "3", + "created_time": "2020-04-30T21:30:22Z", + "updated_time": "2020-04-30T21:30:22Z" + }, + { + "id": "13", + "name": "white-glove", + "parent_id": "3", + "created_time": "2020-05-22T13:04:35Z", + "updated_time": "2020-05-22T13:04:35Z" + }, + { + "id": "14", + "name": "sol-eng", + "parent_id": "3", + "created_time": "2020-06-22T16:52:18Z", + "updated_time": "2020-06-22T16:52:18Z" + }, + { + "id": "29", + "name": "product management", + "parent_id": "3", + "created_time": "2020-08-17T20:16:24Z", + "updated_time": "2020-08-17T20:16:24Z" + }, + { + "id": "31", + "name": "RSC", + "parent_id": "3", + "created_time": "2020-08-17T20:16:45Z", + "updated_time": "2020-08-17T20:16:45Z" + }, + { + "id": "33", + "name": "academy", + "parent_id": "3", + "created_time": "2021-10-18T18:37:56Z", + "updated_time": "2021-10-18T18:37:56Z" + }, + { + "id": "34", + "name": "Support", + "parent_id": "3", + "created_time": "2023-05-18T16:41:59Z", + "updated_time": "2023-05-18T16:41:59Z" + } +] diff --git a/tests/posit/connect/test_tags.py b/tests/posit/connect/test_tags.py new file mode 100644 index 00000000..7cb9e2bd --- /dev/null +++ b/tests/posit/connect/test_tags.py @@ -0,0 +1,299 @@ +import responses +from responses import matchers + +# from responses import matchers +from posit.connect.client import Client +from posit.connect.content import ContentItem +from posit.connect.tags import Tag + +from .api import load_mock_dict, load_mock_list + + +class TestTags: + @responses.activate + def test_find_all_tags(self): + """Check GET /v1/tags""" + # behavior + mock_get_tags = responses.get( + "https://connect.example/__api__/v1/tags", + json=load_mock_list("v1/tags.json"), + ) + + # setup + client = Client(api_key="12345", url="https://connect.example") + + # invoke + tags = client.tags.find() + + # assert + assert mock_get_tags.call_count == 1 + assert len(tags) == 28 + + for tag in tags: + assert isinstance(tag, Tag) + + @responses.activate + def test_find_tags_by_name(self): + """Check GET /v1/tags?name=tag_name""" + # behavior + mock_get_tags = responses.get( + "https://connect.example/__api__/v1/tags?name=academy", + json=load_mock_list("v1/tags?name=academy.json"), + match=[matchers.query_param_matcher({"name": "academy"})], + ) + + # setup + client = Client(api_key="12345", url="https://connect.example") + + # invoke + tags = client.tags.find(name="academy") + + # assert + assert mock_get_tags.call_count == 1 + assert len(tags) == 1 + + for tag in tags: + assert isinstance(tag, Tag) + + @responses.activate + def test_find_tags_by_parent(self): + """Check GET /v1/tags?parent_id=3""" + # behavior + mock_get_tags = responses.get( + "https://connect.example/__api__/v1/tags?parent_id=3", + json=load_mock_list("v1/tags?parent_id=3.json"), + match=[matchers.query_param_matcher({"parent_id": "3"})], + ) + + # setup + client = Client(api_key="12345", url="https://connect.example") + + # invoke + by_str_tags = client.tags.find(parent="3") + parent_tag = Tag(client._ctx, "/v1/tags/3", id="3", name="Parent") + by_tag_tags = client.tags.find(parent=parent_tag) + by_parent_id_tags = client.tags.find( + parent_id=3, # pyright: ignore[reportCallIssue] + ) + + # assert + assert mock_get_tags.call_count == 3 + assert len(by_str_tags) == 7 + assert len(by_tag_tags) == 7 + assert len(by_parent_id_tags) == 7 + + @responses.activate + def test_get(self): + # behavior + mock_get_tag = responses.get( + "https://connect.example/__api__/v1/tags/33", + json=load_mock_dict("v1/tags/33.json"), + ) + + # setup + client = Client(api_key="12345", url="https://connect.example") + + # invoke + tag = client.tags.get("33") + + # assert + assert mock_get_tag.call_count == 1 + assert isinstance(tag, Tag) + + @responses.activate + def test_create_tag(self): + # behavior + mock_create_tag = responses.post( + "https://connect.example/__api__/v1/tags", + json=load_mock_dict("v1/tags/33.json"), + ) + + # setup + client = Client(api_key="12345", url="https://connect.example") + + tag = Tag(client._ctx, "/v1/tags/1", id="3", name="Tag") + + # invoke + academy_tag_parent_id = client.tags.create(name="academy", parent=tag["id"]) + academy_tag_parent_tag = client.tags.create(name="academy", parent=tag) + + # assert + assert mock_create_tag.call_count == 2 + + assert academy_tag_parent_id["id"] == "33" + assert academy_tag_parent_tag["id"] == "33" + + +class TestTag: + @responses.activate + def test_parent(self): + # behavior + mock_get_3_tag = responses.get( + "https://connect.example/__api__/v1/tags/3", + json=load_mock_dict("v1/tags/3.json"), + ) + mock_get_33_tag = responses.get( + "https://connect.example/__api__/v1/tags/33", + json=load_mock_dict("v1/tags/33.json"), + ) + + # setup + client = Client(api_key="12345", url="https://connect.example") + + # invoke + tag = client.tags.get("33") + parent_tag = tag.parent_tag + + assert isinstance(parent_tag, Tag) + parent_parent_tag = parent_tag.parent_tag + + # assert + assert mock_get_3_tag.call_count == 1 + assert mock_get_33_tag.call_count == 1 + + assert parent_parent_tag is None + + @responses.activate + def test_children(self): + # behavior + mock_get_3_tag = responses.get( + "https://connect.example/__api__/v1/tags/3", + json=load_mock_dict("v1/tags/3.json"), + ) + mock_all_tags = responses.get( + "https://connect.example/__api__/v1/tags", + json=load_mock_list("v1/tags.json"), + ) + + # setup + client = Client(api_key="12345", url="https://connect.example") + + # invoke + tag = client.tags.get("3") + tag_children = tag.children_tags.find() + + # assert + assert mock_get_3_tag.call_count == 1 + assert mock_all_tags.call_count == 1 + + assert len(tag_children) == 7 + + for tag_child in tag_children: + assert isinstance(tag_child, Tag) + + @responses.activate + def test_descendants(self): + # behavior + mock_get_3_tag = responses.get( + "https://connect.example/__api__/v1/tags/3", + json=load_mock_dict("v1/tags/3.json"), + ) + mock_all_tags = responses.get( + "https://connect.example/__api__/v1/tags", + json=load_mock_list("v1/tags.json"), + ) + + # setup + client = Client(api_key="12345", url="https://connect.example") + + # invoke + tag = client.tags.get("3") + tag_children = tag.descendant_tags.find() + + # assert + assert mock_get_3_tag.call_count == 1 + assert mock_all_tags.call_count == 1 + + assert len(tag_children) == 21 + + for tag_child in tag_children: + assert isinstance(tag_child, Tag) + + @responses.activate + def test_content_with_tag(self): + # behavior + mock_get_3_tag = responses.get( + "https://connect.example/__api__/v1/tags/3/content", + json=load_mock_list("v1/tags/3/content.json"), + ) + mock_get_33_tag = responses.get( + "https://connect.example/__api__/v1/tags/33/content", + json=load_mock_list("v1/tags/33/content.json"), + ) + + # setup + client = Client(api_key="12345", url="https://connect.example") + tag3 = Tag(client._ctx, "/v1/tags/3", id="3", name="Tag3") + tag33 = Tag(client._ctx, "/v1/tags/33", id="33", name="Tag33") + + # invoke + tag3_content_items = tag3.content_items.find() + tag33_content_items = tag33.content_items.find() + + # assert + assert mock_get_3_tag.call_count == 1 + assert mock_get_33_tag.call_count == 1 + assert len(tag3_content_items) == 0 + assert len(tag33_content_items) == 5 + + for content_item in tag33_content_items: + assert isinstance(content_item, ContentItem) + + @responses.activate + def test_destroy(self): + # behavior + # mock_all_tags = responses.get( + # "https://connect.example/__api__/v1/tags", + # json=load_mock_list("v1/tags.json"), + # ) + mock_29_tag = responses.get( + "https://connect.example/__api__/v1/tags/29", + json=load_mock_dict("v1/tags/29.json"), + ) + mock_29_destroy = responses.delete( + "https://connect.example/__api__/v1/tags/29", + json={}, # empty response + ) + # post_destroy_json = load_mock_list("v1/tags.json") + # for tag in post_destroy_json: + # if tag["id"] in {"29", "30"}: + # post_destroy_json.remove(tag) + # mock_all_tags_after_destroy = responses.get( + # "https://connect.example/__api__/v1/tags", + # json=post_destroy_json, + # ) + + # setup + client = Client(api_key="12345", url="https://connect.example") + + # invoke + # tags = client.tags.find() + # assert len(tags) == 28 + tag29 = client.tags.get("29") + tag29.destroy() + # tags = client.tags.find() + # # All children tags are removed + # assert len(tags) == 26 + + # assert + # assert mock_all_tags.call_count == 1 + assert mock_29_tag.call_count == 1 + assert mock_29_destroy.call_count == 1 + # assert mock_all_tags_after_destroy.call_count == 1 + + +class TestContentItemTags: + @responses.activate + def test_find(self): + # TODO-barret + raise NotImplementedError + + @responses.activate + def test_add(self): + # TODO-barret + raise NotImplementedError + + @responses.activate + def test_delete(self): + # TODO-barret + raise NotImplementedError From 061c667c4dd760c516c2a8ceb230032e8bcada3e Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 26 Nov 2024 16:53:10 -0500 Subject: [PATCH 02/20] Remove unused codes --- src/posit/connect/tags.py | 141 ++------------------------------------ 1 file changed, 6 insertions(+), 135 deletions(-) diff --git a/src/posit/connect/tags.py b/src/posit/connect/tags.py index 2cb4ae79..78aeb524 100644 --- a/src/posit/connect/tags.py +++ b/src/posit/connect/tags.py @@ -1,27 +1,3 @@ -# ## Return all tags -# client.tags.find() -> list[Tag] - -# ## Return all tags with name and parent -# client.tags.find(name="tag_name", parent="parent_tag_guid" | parent_tag | None) - -# # Create Tag -# mytag = client.tags.create( -# name="tag_name", -# parent="parent_tag_guid" | parent_tag | None -# ) -> Tag - -# # Delete Tag - -# mytag = client.tags.get("tag_guid") -# mytag.destroy() -> None - - -# # Find content using tags -# mycontentitems = mytag.content.find() -> list[ContentItem] - -# # Get content item's tags -# mycontentitem: ContentItem = mycontentitems[0] -# mycontentitem.tags.find() -> list[Tag] from __future__ import annotations from typing import TYPE_CHECKING, Optional @@ -200,61 +176,6 @@ def find(self) -> list[Tag]: return child_tags -# def _unique_content_items_for_tags(tags: list[Tag]) -> list[ContentItem]: -# tag_content_items: list[ContentItem] = [] -# content_item_seen = set[str]() -# for descendant_tag in tags: -# for content_item in descendant_tag.content_items.find(): -# if content_item["guid"] not in content_item_seen: -# tag_content_items.append(content_item) -# content_item_seen.add(content_item["guid"]) -# return tag_content_items - - -# class ChildrenTagsContentItems(ContextManager): -# def __init__(self, ctx: Context, path: str, /, *, parent_tag: Tag) -> None: -# super().__init__() -# self._ctx = ctx -# self._path = path -# self._parent_tag = parent_tag - -# def find(self) -> list[ContentItem]: -# """ -# Find all unique content items that are tagged with a tag or any of the tag's descendants. - -# Returns -# ------- -# list[ContentItem] -# List of content items that contain a tag or any of its descendant tags. -# """ -# descendant_tags = ChildrenTags( -# self._ctx, -# self._path, -# parent_tag=self._parent_tag, -# ).find() -# return _unique_content_items_for_tags([self._parent_tag, *descendant_tags]) - - -# class DescendantTagsContentItems(ContextManager): -# def __init__(self, ctx: Context, path: str, /, *, parent_tag: Tag) -> None: -# super().__init__() -# self._ctx = ctx -# self._path = path -# self._parent_tag = parent_tag - -# def find(self) -> list[ContentItem]: -# """ -# Find all unique content items that are tagged with a tag or any of the tag's descendants. - -# Returns -# ------- -# list[ContentItem] -# List of content items that contain a tag or any of its descendant tags. -# """ -# descendant_tags = DescendantTags(self._ctx, parent_tag=self._parent_tag).find() -# return _unique_content_items_for_tags([self._parent_tag, *descendant_tags]) - - class DescendantTags(ContextManager): def __init__(self, ctx: Context, /, *, parent_tag: Tag) -> None: super().__init__() @@ -262,19 +183,6 @@ def __init__(self, ctx: Context, /, *, parent_tag: Tag) -> None: self._path = "v1/tags" self._parent_tag = parent_tag - # @property - # def content_items(self) -> DescendantTagsContentItems: - # """ - # Find all unique content items that are tagged with a tag or any of the tag's descendants. - - # Returns - # ------- - # DescendantTagsContentItems - # Helper class that can `.find()` all content items that contain a tag or any of its - # descendant tags. - # """ - # return DescendantTagsContentItems(self._ctx, self._path, parent_tag=self._parent_tag) - def find(self) -> list[Tag]: """ Find all child tags that descend from a single tag. @@ -295,19 +203,14 @@ def find(self) -> list[Tag]: results = response.json() all_tags = [] for result in results: - tag = Tag(self._ctx, f"{self._path}/{result['id']}", **result) + tag = Tag( + self._ctx, + # TODO-barret-future: Replace with `self._ctx.client.tags._path`? + f"{self._path}/{result['id']}", + **result, + ) all_tags.append(tag) - # all_tags = [ - # Tag( - # self._ctx, - # # TODO-barret-future: Replace with `self._ctx.client.tags._path`? - # f"{self._path}/{results['id']}", - # **result, - # ) - # for result in results - # ] - # O(n^2) algorithm to find all child tags # This could be optimized by using a dictionary to store the tags by their parent_id and # then recursively traverse the dictionary to find all child tags. O(2 * n) = O(n) but the @@ -330,38 +233,6 @@ def find(self) -> list[Tag]: return child_tags - # O(n) algorithm to find all child tags - child_tags = [] - parent_ids = {self._root_id} - - # Construct a d - # O(n) - tag_by_parent_id: dict[str, list[Tag]] = {} - for tag in all_tags: - parent_id: str | None = tag.get("parent_id", None) - if parent_id is None: - continue - parent_id = str(parent_id) - if parent_id not in tag_by_parent_id: - tag_by_parent_id[parent_id] = [] - tag_by_parent_id[parent_id].append(tag) - - # O(n) compute space - ret: list[Tag] = [] - parent_ids_seen = set[str]() - while len(parent_ids) > 0: - parent_id = parent_ids.pop() - if parent_id in parent_ids_seen: - continue - parent_ids_seen.add(parent_id) - if parent_id in tag_by_parent_id: - tags = tag_by_parent_id[parent_id] - ret.extend(tags) - for tag in tags: - parent_ids.add(tag["id"]) - - return ret - class Tags(ContextManager): """Content item tags resource.""" From 0ca43a6a58b133c42cc5bcf975854c238502f2c0 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 27 Nov 2024 12:53:14 -0500 Subject: [PATCH 03/20] Add missing tests --- src/posit/connect/tags.py | 18 ++- .../tag-add.json | 7 ++ .../tags.json | 16 +++ tests/posit/connect/test_tags.py | 111 +++++++++++++++++- 4 files changed, 140 insertions(+), 12 deletions(-) create mode 100644 tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/tag-add.json create mode 100644 tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/tags.json diff --git a/src/posit/connect/tags.py b/src/posit/connect/tags.py index 78aeb524..66fbccd4 100644 --- a/src/posit/connect/tags.py +++ b/src/posit/connect/tags.py @@ -257,8 +257,10 @@ def get(self, tag_id: str) -> Tag: The tag object. """ # TODO-barret-future: Replace with `self._ctx.client.tags.find(id=tag_id)` - assert isinstance(tag_id, str), "Tag `id` must be a string" - assert tag_id != "", "Tag `id` cannot be an empty string" + if not isinstance(tag_id, str): + raise TypeError("`tag_id` must be a string") + if tag_id == "": + raise ValueError("`tag_id` cannot be an empty string") path = f"{self._path}/{tag_id}" url = self._ctx.url + path response = self._ctx.session.get(url) @@ -292,7 +294,8 @@ def _update_parent_kwargs(self, kwargs: dict) -> dict: parent: str = parent["id"] if isinstance(parent, str): - assert parent != "", "Tag `parent` cannot be an empty string" + if parent == "": + raise ValueError("Tag `parent` cannot be an empty string") ret_kwargs["parent_id"] = parent return ret_kwargs @@ -424,8 +427,10 @@ def _to_tag_ids(self, tags: tuple[str | Tag, ...]) -> list[str]: tag_ids: list[str] = [] for i, tag in enumerate(tags): tag_id = tag["id"] if isinstance(tag, Tag) else tag - assert isinstance(tag_id, str), f"Expected 'tags[{i}]' to be a string. Got: {tag_id}" - assert len(tag_id) > 0, "Expected 'tags[{i}]' value to be non-empty" + if not isinstance(tag_id, str): + raise TypeError(f"Expected 'tags[{i}]' to be a string. Received: {tag_id}") + if tag_id == "": + raise ValueError(f"Expected 'tags[{i}]' to be non-empty. Received: {tag_id}") tag_ids.append(tag_id) @@ -474,5 +479,6 @@ def delete(self, *tags: str | Tag) -> None: url = self._ctx.url + self._path for tag_id in tag_ids: - _ = self._ctx.session.delete(url, json={"tag_id": tag_id}) + tag_url = f"{url}/{tag_id}" + self._ctx.session.delete(tag_url, json={"tag_id": tag_id}) return diff --git a/tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/tag-add.json b/tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/tag-add.json new file mode 100644 index 00000000..0be87097 --- /dev/null +++ b/tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/tag-add.json @@ -0,0 +1,7 @@ +{ + "id": "34", + "name": "Support", + "parent_id": "3", + "created_time": "2023-05-18T16:41:59Z", + "updated_time": "2023-05-18T16:41:59Z" +} diff --git a/tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/tags.json b/tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/tags.json new file mode 100644 index 00000000..baeb6c0c --- /dev/null +++ b/tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/tags.json @@ -0,0 +1,16 @@ +[ + { + "id": "3", + "name": "Internal Solutions", + "parent_id": null, + "created_time": "2019-10-08T19:44:49Z", + "updated_time": "2019-10-08T19:44:49Z" + }, + { + "id": "5", + "name": "Life Cycle", + "parent_id": null, + "created_time": "2019-10-08T19:45:21Z", + "updated_time": "2019-10-08T19:45:21Z" + } +] diff --git a/tests/posit/connect/test_tags.py b/tests/posit/connect/test_tags.py index 7cb9e2bd..1b6d3dff 100644 --- a/tests/posit/connect/test_tags.py +++ b/tests/posit/connect/test_tags.py @@ -1,3 +1,4 @@ +import pytest import responses from responses import matchers @@ -117,6 +118,14 @@ def test_create_tag(self): academy_tag_parent_id = client.tags.create(name="academy", parent=tag["id"]) academy_tag_parent_tag = client.tags.create(name="academy", parent=tag) + with pytest.raises(TypeError): + client.tags.create( + name="academy", + parent=123, # pyright: ignore[reportArgumentType] + ) + with pytest.raises(ValueError): + client.tags.create(name="academy", parent="") + # assert assert mock_create_tag.call_count == 2 @@ -285,15 +294,105 @@ def test_destroy(self): class TestContentItemTags: @responses.activate def test_find(self): - # TODO-barret - raise NotImplementedError + # behavior + content_item_guid = "f2f37341-e21d-3d80-c698-a935ad614066" + mock_get = responses.get( + f"https://connect.example/__api__/v1/content/{content_item_guid}", + json=load_mock_dict(f"v1/content/{content_item_guid}.json"), + ) + mock_tags_get = responses.get( + f"https://connect.example/__api__/v1/content/{content_item_guid}/tags", + json=load_mock_list(f"v1/content/{content_item_guid}/tags.json"), + ) + + # setup + client = Client("https://connect.example", "12345") + content_item = client.content.get(content_item_guid) + + # invoke + tags = content_item.tags.find() + + # assert + assert mock_get.call_count == 1 + assert mock_tags_get.call_count == 1 + assert len(tags) == 2 @responses.activate def test_add(self): - # TODO-barret - raise NotImplementedError + # behavior + content_item_guid = "f2f37341-e21d-3d80-c698-a935ad614066" + tag_id = "33" + mock_content_item_get = responses.get( + f"https://connect.example/__api__/v1/content/{content_item_guid}", + json=load_mock_dict(f"v1/content/{content_item_guid}.json"), + ) + mock_tag_get = responses.get( + f"https://connect.example/__api__/v1/tags/{tag_id}", + json=load_mock_dict(f"v1/tags/{tag_id}.json"), + ) + mock_tags_add = responses.post( + f"https://connect.example/__api__/v1/content/{content_item_guid}/tags", + json={}, # empty response + ) + + # setup + client = Client("https://connect.example", "12345") + content_item = client.content.get(content_item_guid) + + sub_tag = client.tags.get("33") + + # invoke + content_item.tags.add(sub_tag["id"]) + content_item.tags.add(sub_tag) + + with pytest.raises(TypeError): + content_item.tags.add( + 123, # pyright: ignore[reportArgumentType] + ) + with pytest.raises(ValueError): + content_item.tags.add("") + + # assert + assert mock_content_item_get.call_count == 1 + assert mock_tag_get.call_count == 1 + assert mock_tags_add.call_count == 2 @responses.activate def test_delete(self): - # TODO-barret - raise NotImplementedError + # behavior + content_item_guid = "f2f37341-e21d-3d80-c698-a935ad614066" + tag_id = "33" + mock_content_item_get = responses.get( + f"https://connect.example/__api__/v1/content/{content_item_guid}", + json=load_mock_dict(f"v1/content/{content_item_guid}.json"), + ) + mock_tag_get = responses.get( + f"https://connect.example/__api__/v1/tags/{tag_id}", + json=load_mock_dict(f"v1/tags/{tag_id}.json"), + ) + mock_tags_delete = responses.delete( + f"https://connect.example/__api__/v1/content/{content_item_guid}/tags/{tag_id}", + json={}, # empty response + ) + + # setup + client = Client("https://connect.example", "12345") + content_item = client.content.get(content_item_guid) + + sub_tag = client.tags.get("33") + + # invoke + content_item.tags.delete(sub_tag["id"]) + content_item.tags.delete(sub_tag) + + with pytest.raises(TypeError): + content_item.tags.delete( + 123, # pyright: ignore[reportArgumentType] + ) + with pytest.raises(ValueError): + content_item.tags.delete("") + + # assert + assert mock_content_item_get.call_count == 1 + assert mock_tag_get.call_count == 1 + assert mock_tags_delete.call_count == 2 From 389dd2441234c78775b9abaea7f46a27d30222f7 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Mon, 2 Dec 2024 10:57:30 -0500 Subject: [PATCH 04/20] Remove json when deleting tag --- src/posit/connect/tags.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/posit/connect/tags.py b/src/posit/connect/tags.py index 66fbccd4..8adfa037 100644 --- a/src/posit/connect/tags.py +++ b/src/posit/connect/tags.py @@ -480,5 +480,5 @@ def delete(self, *tags: str | Tag) -> None: url = self._ctx.url + self._path for tag_id in tag_ids: tag_url = f"{url}/{tag_id}" - self._ctx.session.delete(tag_url, json={"tag_id": tag_id}) + self._ctx.session.delete(tag_url) return From 01471e92aa36748ce7a5e997c99c106a3886fae4 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 3 Dec 2024 14:46:18 -0500 Subject: [PATCH 05/20] Separate out `parent=` into `parent=` or `parent_id=`. Use overloads to help create combinations of each parameter --- src/posit/connect/permissions.py | 2 +- src/posit/connect/tags.py | 66 +++++++++++++++++--------------- tests/posit/connect/test_tags.py | 18 ++++----- 3 files changed, 46 insertions(+), 40 deletions(-) diff --git a/src/posit/connect/permissions.py b/src/posit/connect/permissions.py index 5806e019..08b13de9 100644 --- a/src/posit/connect/permissions.py +++ b/src/posit/connect/permissions.py @@ -158,7 +158,7 @@ def destroy(self, *permissions: str | Group | User | Permission) -> list[Permiss Returns ------- list[Permission] - The removed permissions. If a permission is not found, there is nothing to remove and + The removed permissions. If a permission is not found, there is nothing to remove and it is not included in the returned list. Examples diff --git a/src/posit/connect/tags.py b/src/posit/connect/tags.py index 8adfa037..a976c406 100644 --- a/src/posit/connect/tags.py +++ b/src/posit/connect/tags.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, Optional, overload from typing_extensions import NotRequired, TypedDict, Unpack @@ -266,42 +266,37 @@ def get(self, tag_id: str) -> Tag: response = self._ctx.session.get(url) return Tag(self._ctx, path, **response.json()) - class _NameParentAttrs(TypedDict, total=False): - name: str - """ - The name of the tag. - - Note: tag names are only unique within the scope of a parent, which - means that it is possible to have multiple results when querying by - name; however, querying by both `name` and `parent` ensures a single - result. - """ - parent: NotRequired[str | Tag | None] - """The identifier for the parent tag. If there is no parent, the tag is a top-level tag.""" - def _update_parent_kwargs(self, kwargs: dict) -> dict: parent = kwargs.get("parent", None) if parent is None: + # No parent to upgrade, return the kwargs as is return kwargs + if not isinstance(parent, Tag): + raise TypeError( + "`parent=` must be a Tag instance. If using a string, please use `parent_id=`" + ) + + parent_id = kwargs.get("parent_id", None) + if parent_id: + raise ValueError("Cannot provide both `parent=` and `parent_id=`") + ret_kwargs = {**kwargs} # Remove `parent` from ret_kwargs # and store the `parent_id` in the ret_kwargs below del ret_kwargs["parent"] - if isinstance(parent, Tag): - parent: str = parent["id"] + ret_kwargs["parent_id"] = parent["id"] + return ret_kwargs - if isinstance(parent, str): - if parent == "": - raise ValueError("Tag `parent` cannot be an empty string") - ret_kwargs["parent_id"] = parent - return ret_kwargs + # Allow for every combination of `name` and (`parent` or `parent_id`) + @overload + def find(self, /, *, name: str = ..., parent: Tag = ...) -> list[Tag]: ... + @overload + def find(self, /, *, name: str = ..., parent_id: str = ...) -> list[Tag]: ... - raise TypeError("`parent=` must be a string or Tag instance") - - def find(self, /, **kwargs: Unpack[Tags._NameParentAttrs]) -> list[Tag]: + def find(self, /, **kwargs) -> list[Tag]: """ Find tags by name and/or parent. @@ -313,7 +308,10 @@ def find(self, /, **kwargs: Unpack[Tags._NameParentAttrs]) -> list[Tag]: ---------- name : str, optional The name of the tag. - parent : str, Tag, optional + parent : Tag, optional + The parent `Tag` object. If there is no parent, the tag is a top-level tag. Only one of + `parent` or `parent_id` can be provided. + parent_id : str, optional The identifier for the parent tag. If there is no parent, the tag is a top-level tag. Returns @@ -348,7 +346,14 @@ def find(self, /, **kwargs: Unpack[Tags._NameParentAttrs]) -> list[Tag]: results = response.json() return [Tag(self._ctx, f"{self._path}/{result['id']}", **result) for result in results] - def create(self, /, **kwargs: Unpack[Tags._NameParentAttrs]) -> Tag: + @overload + def create(self, /, *, name: str) -> Tag: ... + @overload + def create(self, /, *, name: str, parent: Tag) -> Tag: ... + @overload + def create(self, /, *, name: str, parent_id: str) -> Tag: ... + + def create(self, /, **kwargs) -> Tag: """ Create a tag. @@ -356,7 +361,10 @@ def create(self, /, **kwargs: Unpack[Tags._NameParentAttrs]) -> Tag: ---------- name : str The name of the tag. - parent : str, Tag, optional + parent : Tag, optional + The parent `Tag` object. If there is no parent, the tag is a top-level tag. Only one of + `parent` or `parent_id` can be provided. + parent_id : str, optional The identifier for the parent tag. If there is no parent, the tag is a top-level tag. Returns @@ -456,8 +464,7 @@ def add(self, *tags: str | Tag) -> None: url = self._ctx.url + self._path for tag_id in tag_ids: - _ = self._ctx.session.post(url, json={"tag_id": tag_id}) - return + self._ctx.session.post(url, json={"tag_id": tag_id}) def delete(self, *tags: str | Tag) -> None: """ @@ -481,4 +488,3 @@ def delete(self, *tags: str | Tag) -> None: for tag_id in tag_ids: tag_url = f"{url}/{tag_id}" self._ctx.session.delete(tag_url) - return diff --git a/tests/posit/connect/test_tags.py b/tests/posit/connect/test_tags.py index 1b6d3dff..8b9a00b7 100644 --- a/tests/posit/connect/test_tags.py +++ b/tests/posit/connect/test_tags.py @@ -70,18 +70,14 @@ def test_find_tags_by_parent(self): client = Client(api_key="12345", url="https://connect.example") # invoke - by_str_tags = client.tags.find(parent="3") + by_str_tags = client.tags.find(parent_id="3") parent_tag = Tag(client._ctx, "/v1/tags/3", id="3", name="Parent") by_tag_tags = client.tags.find(parent=parent_tag) - by_parent_id_tags = client.tags.find( - parent_id=3, # pyright: ignore[reportCallIssue] - ) # assert - assert mock_get_tags.call_count == 3 + assert mock_get_tags.call_count == 2 assert len(by_str_tags) == 7 assert len(by_tag_tags) == 7 - assert len(by_parent_id_tags) == 7 @responses.activate def test_get(self): @@ -115,16 +111,20 @@ def test_create_tag(self): tag = Tag(client._ctx, "/v1/tags/1", id="3", name="Tag") # invoke - academy_tag_parent_id = client.tags.create(name="academy", parent=tag["id"]) + academy_tag_parent_id = client.tags.create(name="academy", parent_id=tag["id"]) academy_tag_parent_tag = client.tags.create(name="academy", parent=tag) with pytest.raises(TypeError): client.tags.create( name="academy", - parent=123, # pyright: ignore[reportArgumentType] + parent="not a tag", # pyright: ignore[reportArgumentType] ) with pytest.raises(ValueError): - client.tags.create(name="academy", parent="") + client.tags.create( # pyright: ignore[reportCallIssue] + name="academy", + parent=tag, + parent_id="asdf", + ) # assert assert mock_create_tag.call_count == 2 From b98edf2e9e5902700c26ad5f8268d6dd40d87132 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 3 Dec 2024 15:15:04 -0500 Subject: [PATCH 06/20] Clean up integration test --- integration/tests/posit/connect/test_tags.py | 182 ++++++++++++------- src/posit/connect/tags.py | 1 - 2 files changed, 115 insertions(+), 68 deletions(-) diff --git a/integration/tests/posit/connect/test_tags.py b/integration/tests/posit/connect/test_tags.py index 41cff9b2..7604e300 100644 --- a/integration/tests/posit/connect/test_tags.py +++ b/integration/tests/posit/connect/test_tags.py @@ -1,34 +1,27 @@ from typing import TYPE_CHECKING -import pytest -from packaging import version - from posit import connect -from . import CONNECT_VERSION - if TYPE_CHECKING: from posit.connect.content import ContentItem # add integration tests here! -@pytest.mark.skipif( - CONNECT_VERSION < version.parse("2024.10.0-dev"), - reason="Packages API unavailable", -) class TestTags: @classmethod def setup_class(cls): cls.client = connect.Client() - cls.contentA = cls.client.content.create(name=cls.__name__) - cls.contentB = cls.client.content.create(name=cls.__name__) - cls.contentC = cls.client.content.create( - name=cls.__name__, - ) + cls.contentA = cls.client.content.create(name="Content_A") + cls.contentB = cls.client.content.create(name="Content_B") + cls.contentC = cls.client.content.create(name="Content_C") @classmethod def teardown_class(cls): assert len(cls.client.tags.find()) == 0 + cls.contentA.delete() + cls.contentB.delete() + cls.contentC.delete() + assert len(cls.client.content.find()) == 0 def test_tags_find(self): tags = self.client.tags.find() @@ -37,7 +30,7 @@ def test_tags_find(self): def test_tags_create_destroy(self): tagA = self.client.tags.create(name="tagA") tagB = self.client.tags.create(name="tagB") - tagC = self.client.tags.create(name="tagC", parent=self.tagA) + tagC = self.client.tags.create(name="tagC", parent=tagA) assert len(self.client.tags.find()) == 3 @@ -49,78 +42,133 @@ def test_tags_create_destroy(self): assert len(self.client.tags.find()) == 0 def test_tag_descendants(self): - # Have created tags persist - self.tagA = self.client.tags.create(name="tagA") - self.tagB = self.client.tags.create(name="tagB") - self.tagC = self.client.tags.create(name="tagC", parent=self.tagA) - self.tagD = self.client.tags.create(name="tagD", parent=self.tagC) + tagA = self.client.tags.create(name="tagA") + tagB = self.client.tags.create(name="tagB") + tagC = self.client.tags.create(name="tagC", parent=tagA) + tagD = self.client.tags.create(name="tagD", parent=tagC) - assert self.tagA.descendant_tags.find() == [self.tagC, self.tagD] + assert tagA.descendant_tags.find() == [tagC, tagD] - assert len(self.tagB.descendant_tags.find()) == 0 - assert len(self.tagC.descendant_tags.find()) == [self.tagD] + assert len(tagB.descendant_tags.find()) == 0 + assert tagC.descendant_tags.find() == [tagD] + + # cleanup + tagA.destroy() + tagB.destroy() + assert len(self.client.tags.find()) == 0 def test_tag_children(self): - assert self.tagA.children_tags.find() == [self.tagC] - assert self.tagB.children_tags.find() == [] - assert self.tagC.children_tags.find() == [self.tagD] + tagA = self.client.tags.create(name="tagA_children") + tagB = self.client.tags.create(name="tagB_children") + tagC = self.client.tags.create(name="tagC_children", parent=tagA) + tagD = self.client.tags.create(name="tagD_children", parent=tagC) + + assert tagA.children_tags.find() == [tagC] + assert tagB.children_tags.find() == [] + assert tagC.children_tags.find() == [tagD] + + # cleanup + tagA.destroy() + tagB.destroy() + assert len(self.client.tags.find()) == 0 def test_tag_parent(self): - assert self.tagA.parent_tag is None - assert self.tagB.parent_tag is None - assert self.tagC.parent_tag == self.tagA - assert self.tagD.parent_tag == self.tagC + tagA = self.client.tags.create(name="tagA_parent") + tagB = self.client.tags.create(name="tagB_parent") + tagC = self.client.tags.create(name="tagC_parent", parent=tagA) + tagD = self.client.tags.create(name="tagD_parent", parent=tagC) + + assert tagA.parent_tag is None + assert tagB.parent_tag is None + assert tagC.parent_tag == tagA + assert tagD.parent_tag == tagC + + # cleanup + tagA.destroy() + tagB.destroy() + assert len(self.client.tags.find()) == 0 + + def test_content_item_tags(self): + tagRoot = self.client.tags.create(name="tagRoot_content_item_tags") + tagA = self.client.tags.create(name="tagA_content_item_tags", parent=tagRoot) + tagB = self.client.tags.create(name="tagB_content_item_tags", parent=tagRoot) + tagC = self.client.tags.create(name="tagC_content_item_tags", parent=tagA) + tagD = self.client.tags.create(name="tagD_content_item_tags", parent=tagC) - def test_content_a_tags(self): assert len(self.contentA.tags.find()) == 0 - self.contentA.tags.add(self.tagA) - self.contentA.tags.add(self.tagB) + self.contentA.tags.add(tagD) + self.contentA.tags.add(tagB) - # tagB, tagC, tagA (parent of tagC) - assert len(self.contentA.tags.find()) == 3 + # tagB, tagD, tagC (parent of tagD), tagA (parent of tagC) + # tagD + tagC + # tagRoot is considered a "category" and is "not a tag" + assert len(self.contentA.tags.find()) == 4 - self.contentA.tags.delete(self.tagB) - assert len(self.contentA.tags.find()) == 2 + # Removes tagB + self.contentA.tags.delete(tagB) + assert len(self.contentA.tags.find()) == 4 - 1 - # Removes tagC and tagA (parent of tagC) - self.contentA.tags.delete(self.tagA) - assert len(self.contentA.tags.find()) == 0 + # Removes tagC and tagD (parent of tagC) + self.contentA.tags.delete(tagC) + assert len(self.contentA.tags.find()) == 4 - 1 - 2 - def test_tags_content_items(self): - assert len(self.tagA.content_items.find()) == 0 - assert len(self.tagB.content_items.find()) == 0 - assert len(self.tagC.content_items.find()) == 0 + # cleanup + tagRoot.destroy() + assert len(self.client.tags.find()) == 0 - self.contentA.tags.add(self.tagA) - self.contentA.tags.add(self.tagB) + def test_tag_content_items(self): + tagRoot = self.client.tags.create(name="tagRoot_tag_content_items") + tagA = self.client.tags.create(name="tagA_tag_content_items", parent=tagRoot) + tagB = self.client.tags.create(name="tagB_tag_content_items", parent=tagRoot) + tagC = self.client.tags.create(name="tagC_tag_content_items", parent=tagA) + tagD = self.client.tags.create(name="tagD_tag_content_items", parent=tagC) - self.contentB.tags.add(self.tagA) - self.contentB.tags.add(self.tagC) + assert len(tagA.content_items.find()) == 0 + assert len(tagB.content_items.find()) == 0 + assert len(tagC.content_items.find()) == 0 + assert len(tagD.content_items.find()) == 0 - self.contentC.tags.add(self.tagC) + self.contentA.tags.add(tagD) + self.contentA.tags.add(tagB) - assert len(self.contentA.tags.find()) == 2 + self.contentB.tags.add(tagA) + self.contentB.tags.add(tagC) + + self.contentC.tags.add(tagC) + + assert len(self.contentA.tags.find()) == 4 assert len(self.contentB.tags.find()) == 2 - assert len(self.contentC.tags.find()) == 1 + assert len(self.contentC.tags.find()) == 2 - assert len(self.tagA.content_items.find()) == 2 - assert len(self.tagB.content_items.find()) == 1 - assert len(self.tagC.content_items.find()) == 2 + assert len(tagA.content_items.find()) == 3 + assert len(tagB.content_items.find()) == 1 + assert len(tagC.content_items.find()) == 3 # Make sure unique content items are found content_items_list: list[ContentItem] = [] - for tag in [self.tagA, *self.tagA.descendant_tags.find()]: + for tag in [tagA, *tagA.descendant_tags.find()]: content_items_list.extend(tag.content_items.find()) - # Get unique items - content_items_list = list(set(content_items_list)) - - assert content_items_list == [self.contentA, self.contentB, self.contentC] - - self.contentA.tags.delete(self.tagA, self.tagB) - self.contentB.tags.delete(self.tagA) - self.contentC.tags.delete(self.tagC) - - assert len(self.tagA.content_items.find()) == 0 - assert len(self.tagB.content_items.find()) == 0 - assert len(self.tagC.content_items.find()) == 0 + # Get unique content_item guids + content_item_guids = set[str]() + for content_item in content_items_list: + if content_item["guid"] not in content_item_guids: + content_item_guids.add(content_item["guid"]) + + assert content_item_guids == { + self.contentA["guid"], + self.contentB["guid"], + self.contentC["guid"], + } + + self.contentA.tags.delete(tagRoot) + self.contentB.tags.delete(tagRoot) + self.contentC.tags.delete(tagRoot) + + assert len(tagA.content_items.find()) == 0 + assert len(tagB.content_items.find()) == 0 + assert len(tagC.content_items.find()) == 0 + + # cleanup + tagRoot.destroy() + assert len(self.client.tags.find()) == 0 diff --git a/src/posit/connect/tags.py b/src/posit/connect/tags.py index a976c406..8091d23d 100644 --- a/src/posit/connect/tags.py +++ b/src/posit/connect/tags.py @@ -142,7 +142,6 @@ def find(self) -> list[ContentItem]: response = self._ctx.session.get(url) results = response.json() params = ResourceParameters(self._ctx.session, self._ctx.url) - print(results) return [ContentItem(params, **result) for result in results] From b82ca52714c05095f2cf936de5a79739cfadf968 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Thu, 5 Dec 2024 13:57:53 -0500 Subject: [PATCH 07/20] Resolve TODOs --- src/posit/connect/tags.py | 31 ++++++------------------------- 1 file changed, 6 insertions(+), 25 deletions(-) diff --git a/src/posit/connect/tags.py b/src/posit/connect/tags.py index 8091d23d..f6015179 100644 --- a/src/posit/connect/tags.py +++ b/src/posit/connect/tags.py @@ -5,7 +5,7 @@ from typing_extensions import NotRequired, TypedDict, Unpack from .context import Context, ContextManager -from .resources import Active, ResourceParameters +from .resources import Active if TYPE_CHECKING: from .content import ContentItem @@ -34,11 +34,8 @@ def parent_tag(self) -> Tag | None: if self.get("parent_id", None) is None: return None - # TODO-barret-future: Replace with `self._ctx.client.tags.get(self["parent_id"])` - path = "v1/tags/" + self["parent_id"] - url = self._ctx.url + path - response = self._ctx.session.get(url) - return Tag(self._ctx, path, **response.json()) + parent = self._ctx.client.tags.get(self["parent_id"]) + return parent @property def children_tags(self) -> ChildrenTags: @@ -141,8 +138,7 @@ def find(self) -> list[ContentItem]: url = self._ctx.url + self._path response = self._ctx.session.get(url) results = response.json() - params = ResourceParameters(self._ctx.session, self._ctx.url) - return [ContentItem(params, **result) for result in results] + return [ContentItem(self._ctx, **result) for result in results] class ChildrenTags(ContextManager): @@ -161,10 +157,7 @@ def find(self) -> list[Tag]: list[Tag] List of child tags. (Does not include the parent tag.) """ - # TODO-future-barret; - # This method could be done with `self._ctx.client.tags.find(parent=self)` - # For now, use DescendantTags and filter the results - descendant_tags = DescendantTags(self._ctx, parent_tag=self._parent_tag).find() + descendant_tags = self._ctx.client.tags.find(parent=self._parent_tag) # Filter out tags that are not direct children child_tags: list[Tag] = [] @@ -196,19 +189,7 @@ def find(self) -> list[Tag]: # By using the `/v1/tags` endpoint, we can get all tags in a single request # and filter them in Python. - # TODO-barret-future: Replace with `self._ctx.client.tags.find(parent=self._root_id)` - url = self._ctx.url + self._path - response = self._ctx.session.get(url) - results = response.json() - all_tags = [] - for result in results: - tag = Tag( - self._ctx, - # TODO-barret-future: Replace with `self._ctx.client.tags._path`? - f"{self._path}/{result['id']}", - **result, - ) - all_tags.append(tag) + all_tags = self._ctx.client.tags.find() # O(n^2) algorithm to find all child tags # This could be optimized by using a dictionary to store the tags by their parent_id and From b4f418e77873783a955e94098cfe02eea0afe5d0 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Thu, 5 Dec 2024 13:59:20 -0500 Subject: [PATCH 08/20] Update algo to remove tags with no parents for faster second pass --- src/posit/connect/tags.py | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/src/posit/connect/tags.py b/src/posit/connect/tags.py index f6015179..ee765623 100644 --- a/src/posit/connect/tags.py +++ b/src/posit/connect/tags.py @@ -192,24 +192,30 @@ def find(self) -> list[Tag]: all_tags = self._ctx.client.tags.find() # O(n^2) algorithm to find all child tags + # # This could be optimized by using a dictionary to store the tags by their parent_id and # then recursively traverse the dictionary to find all child tags. O(2 * n) = O(n) but the # code is more complex # - # If the tags are always ordered, it could be performed in a single pass (O(n)) as parents - # always appear before any children + # If the tags are always ordered (which they seem to be ordered by creation date - parents are first), + # this algo be performed in a two passes (one to add all tags, one to confirm no more additions) child_tags = [] parent_ids = {self._parent_tag["id"]} - child_tag_found: bool = True - while child_tag_found: - child_tag_found = False - + tag_found: bool = True + while tag_found: + tag_found = False for tag in [*all_tags]: - if tag.get("parent_id") in parent_ids: + parent_id = tag.get("parent_id") + if not parent_id: + # Skip top-level tags + all_tags.remove(tag) + continue + if parent_id in parent_ids: child_tags.append(tag) parent_ids.add(tag["id"]) + # Child found, remove from search list all_tags.remove(tag) - child_tag_found = True + tag_found = True return child_tags @@ -236,7 +242,6 @@ def get(self, tag_id: str) -> Tag: Tag The tag object. """ - # TODO-barret-future: Replace with `self._ctx.client.tags.find(id=tag_id)` if not isinstance(tag_id, str): raise TypeError("`tag_id` must be a string") if tag_id == "": From df626d2db34aa4fc43264110bce8f24e778a7316 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Thu, 5 Dec 2024 14:30:27 -0500 Subject: [PATCH 09/20] Apply suggestions from code review Co-authored-by: Toph Allen --- src/posit/connect/tags.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/posit/connect/tags.py b/src/posit/connect/tags.py index ee765623..ec9f2834 100644 --- a/src/posit/connect/tags.py +++ b/src/posit/connect/tags.py @@ -38,7 +38,7 @@ def parent_tag(self) -> Tag | None: return parent @property - def children_tags(self) -> ChildrenTags: + def child_tags(self) -> ChildTags: """ Find all child tags that are direct children of this tag. @@ -60,8 +60,6 @@ def children_tags(self) -> ChildrenTags: """ return ChildrenTags(self._ctx, self._path, parent_tag=self) - # TODO-barret-Q: Should this be `.descendant_tags` or `.descendants`? - # TODO-barret-Q: Should this be `.find_descendants() -> list[Tag]`? @property def descendant_tags(self) -> DescendantTags: """ @@ -80,7 +78,7 @@ def descendant_tags(self) -> DescendantTags: client = posit.connect.Client(...) mytag = client.tags.find(id="TAG_ID_HERE") - descendant_tags = mytag.descendant_tags().find() + descendant_tags = mytag.descendant_tags.find() ``` """ return DescendantTags(self._ctx, parent_tag=self) @@ -141,7 +139,7 @@ def find(self) -> list[ContentItem]: return [ContentItem(self._ctx, **result) for result in results] -class ChildrenTags(ContextManager): +class ChildTags(ContextManager): def __init__(self, ctx: Context, path: str, /, *, parent_tag: Tag) -> None: super().__init__() self._ctx = ctx From 6f8fb465efc874eff600388306800d2ef669b9be Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Thu, 5 Dec 2024 14:33:18 -0500 Subject: [PATCH 10/20] Name changes --- integration/tests/posit/connect/test_tags.py | 6 +++--- src/posit/connect/tags.py | 4 ++-- tests/posit/connect/test_tags.py | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/integration/tests/posit/connect/test_tags.py b/integration/tests/posit/connect/test_tags.py index 7604e300..1ce8405c 100644 --- a/integration/tests/posit/connect/test_tags.py +++ b/integration/tests/posit/connect/test_tags.py @@ -63,9 +63,9 @@ def test_tag_children(self): tagC = self.client.tags.create(name="tagC_children", parent=tagA) tagD = self.client.tags.create(name="tagD_children", parent=tagC) - assert tagA.children_tags.find() == [tagC] - assert tagB.children_tags.find() == [] - assert tagC.children_tags.find() == [tagD] + assert tagA.child_tags.find() == [tagC] + assert tagB.child_tags.find() == [] + assert tagC.child_tags.find() == [tagD] # cleanup tagA.destroy() diff --git a/src/posit/connect/tags.py b/src/posit/connect/tags.py index ec9f2834..39d78563 100644 --- a/src/posit/connect/tags.py +++ b/src/posit/connect/tags.py @@ -55,10 +55,10 @@ def child_tags(self) -> ChildTags: client = posit.connect.Client(...) mytag = client.tags.find(id="TAG_ID_HERE") - children = mytag.children_tags().find() + children = mytag.child_tags.find() ``` """ - return ChildrenTags(self._ctx, self._path, parent_tag=self) + return ChildTags(self._ctx, self._path, parent_tag=self) @property def descendant_tags(self) -> DescendantTags: diff --git a/tests/posit/connect/test_tags.py b/tests/posit/connect/test_tags.py index 8b9a00b7..44dba775 100644 --- a/tests/posit/connect/test_tags.py +++ b/tests/posit/connect/test_tags.py @@ -179,7 +179,7 @@ def test_children(self): # invoke tag = client.tags.get("3") - tag_children = tag.children_tags.find() + tag_children = tag.child_tags.find() # assert assert mock_get_3_tag.call_count == 1 From 267f58fbc823f32b6275d449b8e54046febaca02 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Fri, 6 Dec 2024 09:25:50 -0500 Subject: [PATCH 11/20] Add content_items to child / descendant tags --- src/posit/connect/tags.py | 103 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/src/posit/connect/tags.py b/src/posit/connect/tags.py index 39d78563..2de560d9 100644 --- a/src/posit/connect/tags.py +++ b/src/posit/connect/tags.py @@ -146,6 +146,28 @@ def __init__(self, ctx: Context, path: str, /, *, parent_tag: Tag) -> None: self._path = path self._parent_tag = parent_tag + def content_items(self) -> ChildTagContentItems: + """ + Find all content items from the child tags. + + Returns + ------- + ChildTagContentItems + Helper class that can `.find()` all content items that are tagged with a child tag. + + Examples + -------- + ```python + import posit + + client = posit.connect.Client(...) + + mytag = client.tags.find(id="TAG_ID_HERE") + tagged_content_items = mytag.child_tags.content_items.find() + ``` + """ + return ChildTagContentItems(self._ctx, self._path, parent_tag=self._parent_tag) + def find(self) -> list[Tag]: """ Find all child tags that are direct children of a single tag. @@ -166,6 +188,27 @@ def find(self) -> list[Tag]: return child_tags +class ChildTagContentItems(ContextManager): + def __init__(self, ctx: Context, path: str, /, *, parent_tag: Tag) -> None: + super().__init__() + self._ctx = ctx + self._path = path + self._parent_tag = parent_tag + + def find(self) -> list[ContentItem]: + """ + Find all content items that are tagged with a child tag. + + Returns + ------- + list[ContentItem] + List of content items that are tagged with a child tag. + """ + child_tags = self._parent_tag.child_tags.find() + content_items = DescendantTagContentItems._unique_content_items(child_tags) + return content_items + + class DescendantTags(ContextManager): def __init__(self, ctx: Context, /, *, parent_tag: Tag) -> None: super().__init__() @@ -173,6 +216,28 @@ def __init__(self, ctx: Context, /, *, parent_tag: Tag) -> None: self._path = "v1/tags" self._parent_tag = parent_tag + def content_items(self) -> DescendantTagContentItems: + """ + Find all content items from the descendant tags. + + Returns + ------- + DescendantTagContentItems + Helper class that can `.find()` all content items that are tagged with a descendant tag. + + Examples + -------- + ```python + import posit + + client = posit.connect.Client(...) + + mytag = client.tags.find(id="TAG_ID_HERE") + tagged_content_items = mytag.descendant_tags.content_items.find() + ``` + """ + return DescendantTagContentItems(self._ctx, self._path, parent_tag=self._parent_tag) + def find(self) -> list[Tag]: """ Find all child tags that descend from a single tag. @@ -218,6 +283,44 @@ def find(self) -> list[Tag]: return child_tags +class DescendantTagContentItems(ContextManager): + def __init__(self, ctx: Context, path: str, /, *, parent_tag: Tag) -> None: + super().__init__() + self._ctx = ctx + self._path = path + self._parent_tag = parent_tag + + @staticmethod + def _unique_content_items(tags: list[Tag]) -> list[ContentItem]: + content_items: list[ContentItem] = [] + content_items_seen: set[str] = set() + + for tag in tags: + tag_content_items = tag.content_items.find() + + for content_item in tag_content_items: + content_item_guid = content_item["guid"] + + if content_item_guid not in content_items_seen: + content_items.append(content_item) + content_items_seen.add(content_item_guid) + + return content_items + + def find(self) -> list[ContentItem]: + """ + Find all content items that are tagged with a descendant tag. + + Returns + ------- + list[ContentItem] + List of content items that are tagged with a descendant tag. + """ + descendant_tags = self._parent_tag.descendant_tags.find() + content_items = self._unique_content_items(descendant_tags) + return content_items + + class Tags(ContextManager): """Content item tags resource.""" From 2ad2b3a34530645831c6a77edc05415d96d419d1 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Fri, 6 Dec 2024 10:30:48 -0500 Subject: [PATCH 12/20] Update test_tags.py --- tests/posit/connect/test_tags.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/posit/connect/test_tags.py b/tests/posit/connect/test_tags.py index 44dba775..324d88ad 100644 --- a/tests/posit/connect/test_tags.py +++ b/tests/posit/connect/test_tags.py @@ -169,9 +169,10 @@ def test_children(self): "https://connect.example/__api__/v1/tags/3", json=load_mock_dict("v1/tags/3.json"), ) - mock_all_tags = responses.get( + mock_parent_3_tags = responses.get( "https://connect.example/__api__/v1/tags", - json=load_mock_list("v1/tags.json"), + json=load_mock_list("v1/tags?parent_id=3.json"), + match=[matchers.query_param_matcher({"parent_id": "3"})], ) # setup @@ -183,7 +184,7 @@ def test_children(self): # assert assert mock_get_3_tag.call_count == 1 - assert mock_all_tags.call_count == 1 + assert mock_parent_3_tags.call_count == 1 assert len(tag_children) == 7 From 6b5624399a0ef94be4314493539547c08458e561 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Fri, 6 Dec 2024 10:39:50 -0500 Subject: [PATCH 13/20] Combine classes and have ABC methods to implement; Add many examples --- src/posit/connect/tags.py | 246 ++++++++++++++++++++++++++------------ 1 file changed, 170 insertions(+), 76 deletions(-) diff --git a/src/posit/connect/tags.py b/src/posit/connect/tags.py index 2de560d9..e97c1b2d 100644 --- a/src/posit/connect/tags.py +++ b/src/posit/connect/tags.py @@ -1,5 +1,6 @@ from __future__ import annotations +from abc import ABC, abstractmethod from typing import TYPE_CHECKING, Optional, overload from typing_extensions import NotRequired, TypedDict, Unpack @@ -11,6 +12,38 @@ from .content import ContentItem +class _RelatedTagsBase(ContextManager, ABC): + @abstractmethod + def content_items(self) -> _TagContentItemsBase: + pass + + @abstractmethod + def find(self) -> list[Tag]: + pass + + +class _TagContentItemsBase(ContextManager, ABC): + @staticmethod + def _unique_content_items(tags: list[Tag]) -> list[ContentItem]: + content_items: list[ContentItem] = [] + content_items_seen: set[str] = set() + + for tag in tags: + tag_content_items = tag.content_items.find() + + for content_item in tag_content_items: + content_item_guid = content_item["guid"] + + if content_item_guid not in content_items_seen: + content_items.append(content_item) + content_items_seen.add(content_item_guid) + + return content_items + + @abstractmethod + def find(self) -> list[ContentItem]: ... + + class Tag(Active): """Tag resource.""" @@ -52,9 +85,9 @@ def child_tags(self) -> ChildTags: ```python import posit - client = posit.connect.Client(...) - + client = posit.connect.Client() mytag = client.tags.find(id="TAG_ID_HERE") + children = mytag.child_tags.find() ``` """ @@ -75,9 +108,9 @@ def descendant_tags(self) -> DescendantTags: ```python import posit - client = posit.connect.Client(...) - + client = posit.connect.Client() mytag = client.tags.find(id="TAG_ID_HERE") + descendant_tags = mytag.descendant_tags.find() ``` """ @@ -86,7 +119,7 @@ def descendant_tags(self) -> DescendantTags: @property def content_items(self) -> TagContentItems: """ - Find all content items using this tag. + Find all content items that are tagged with this tag. Returns ------- @@ -98,8 +131,9 @@ def content_items(self) -> TagContentItems: ```python import posit - client = posit.connect.Client(...) + client = posit.connect.Client() first_tag = client.tags.find()[0] + first_tag_content_items = first_tag.content_items.find() ``` """ @@ -111,12 +145,24 @@ def destroy(self) -> None: Removes the tag. Deletes a tag, including all descendants in its own tag hierarchy. + + Examples + -------- + ```python + import posit + + client = posit.connect.Client() + first_tag = client.tags.find()[0] + + # Remove the tag + first_tag.destroy() + ``` """ url = self._ctx.url + self._path self._ctx.session.delete(url) -class TagContentItems(ContextManager): +class TagContentItems(_TagContentItemsBase): def __init__(self, ctx: Context, path: str) -> None: super().__init__() self._ctx = ctx @@ -130,6 +176,17 @@ def find(self) -> list[ContentItem]: ------- list[ContentItem] List of content items that are tagged with this tag. + + Examples + -------- + ```python + import posit + + client = posit.connect.Client() + first_tag = client.tags.find()[0] + + first_tag_content_items = first_tag.content_items.find() + ``` """ from .content import ContentItem @@ -139,11 +196,12 @@ def find(self) -> list[ContentItem]: return [ContentItem(self._ctx, **result) for result in results] -class ChildTags(ContextManager): +class ChildTags(_RelatedTagsBase): def __init__(self, ctx: Context, path: str, /, *, parent_tag: Tag) -> None: super().__init__() - self._ctx = ctx - self._path = path + self._ctx: Context = ctx + self._path: str = path + self._parent_tag = parent_tag def content_items(self) -> ChildTagContentItems: @@ -160,9 +218,9 @@ def content_items(self) -> ChildTagContentItems: ```python import posit - client = posit.connect.Client(...) + client = posit.connect.Client() + mytag = client.tags.get("TAG_ID_HERE") - mytag = client.tags.find(id="TAG_ID_HERE") tagged_content_items = mytag.child_tags.content_items.find() ``` """ @@ -176,19 +234,23 @@ def find(self) -> list[Tag]: ------- list[Tag] List of child tags. (Does not include the parent tag.) - """ - descendant_tags = self._ctx.client.tags.find(parent=self._parent_tag) - # Filter out tags that are not direct children - child_tags: list[Tag] = [] - for tag in descendant_tags: - if tag.get("parent_id") == self._parent_tag["id"]: - child_tags.append(tag) + Examples + -------- + ```python + import posit + + client = posit.connect.Client() + mytag = client.tags.get("TAG_ID_HERE") + child_tags = mytag.child_tags.find() + ``` + """ + child_tags = self._ctx.client.tags.find(parent=self._parent_tag) return child_tags -class ChildTagContentItems(ContextManager): +class ChildTagContentItems(_TagContentItemsBase): def __init__(self, ctx: Context, path: str, /, *, parent_tag: Tag) -> None: super().__init__() self._ctx = ctx @@ -203,17 +265,58 @@ def find(self) -> list[ContentItem]: ------- list[ContentItem] List of content items that are tagged with a child tag. + + Examples + -------- + ```python + import posit + + client = posit.connect.Client() + mytag = client.tags.get("TAG_ID_HERE") + + tagged_content_items = mytag.child_tags.content_items.find() + ``` """ child_tags = self._parent_tag.child_tags.find() - content_items = DescendantTagContentItems._unique_content_items(child_tags) + content_items = self._unique_content_items(child_tags) + return content_items + + +class DescendantTagContentItems(_TagContentItemsBase): + def __init__(self, ctx: Context, /, *, parent_tag: Tag) -> None: + super().__init__() + self._ctx = ctx + self._parent_tag = parent_tag + + def find(self) -> list[ContentItem]: + """ + Find all content items that are tagged with a descendant tag. + + Returns + ------- + list[ContentItem] + List of content items that are tagged with a descendant tag. + + Examples + -------- + ```python + import posit + + client = posit.connect.Client() + mytag = client.tags.get("TAG_ID_HERE") + + tagged_content_items = mytag.descendant_tags.content_items.find() + ``` + """ + descendant_tags = self._parent_tag.descendant_tags.find() + content_items = self._unique_content_items(descendant_tags) return content_items -class DescendantTags(ContextManager): +class DescendantTags(_RelatedTagsBase): def __init__(self, ctx: Context, /, *, parent_tag: Tag) -> None: super().__init__() self._ctx = ctx - self._path = "v1/tags" self._parent_tag = parent_tag def content_items(self) -> DescendantTagContentItems: @@ -230,13 +333,16 @@ def content_items(self) -> DescendantTagContentItems: ```python import posit - client = posit.connect.Client(...) - + client = posit.connect.Client() mytag = client.tags.find(id="TAG_ID_HERE") + tagged_content_items = mytag.descendant_tags.content_items.find() ``` """ - return DescendantTagContentItems(self._ctx, self._path, parent_tag=self._parent_tag) + return DescendantTagContentItems( + self._ctx, + parent_tag=self._parent_tag, + ) def find(self) -> list[Tag]: """ @@ -245,16 +351,16 @@ def find(self) -> list[Tag]: Returns ------- list[Tag] - List of tags that desc + List of tags that descend from the parent tag. """ - # This method could be done with `tags.find(parent=self._root_id)` but it would require - # a request for every child tag recursively. + # This method could be done using `tags.find(parent=self._root_id)` but it would require + # a request for every child tag recursively. (O(n) requests) # By using the `/v1/tags` endpoint, we can get all tags in a single request - # and filter them in Python. + # and filter them in Python. (1 request) all_tags = self._ctx.client.tags.find() - # O(n^2) algorithm to find all child tags + # O(n^2) algorithm to find all child tags. O(n) in practice. # # This could be optimized by using a dictionary to store the tags by their parent_id and # then recursively traverse the dictionary to find all child tags. O(2 * n) = O(n) but the @@ -270,57 +376,20 @@ def find(self) -> list[Tag]: for tag in [*all_tags]: parent_id = tag.get("parent_id") if not parent_id: - # Skip top-level tags + # Skip tags with no parent all_tags.remove(tag) continue if parent_id in parent_ids: + # Child found, remove from search list child_tags.append(tag) parent_ids.add(tag["id"]) - # Child found, remove from search list + # Remove from search list all_tags.remove(tag) tag_found = True return child_tags -class DescendantTagContentItems(ContextManager): - def __init__(self, ctx: Context, path: str, /, *, parent_tag: Tag) -> None: - super().__init__() - self._ctx = ctx - self._path = path - self._parent_tag = parent_tag - - @staticmethod - def _unique_content_items(tags: list[Tag]) -> list[ContentItem]: - content_items: list[ContentItem] = [] - content_items_seen: set[str] = set() - - for tag in tags: - tag_content_items = tag.content_items.find() - - for content_item in tag_content_items: - content_item_guid = content_item["guid"] - - if content_item_guid not in content_items_seen: - content_items.append(content_item) - content_items_seen.add(content_item_guid) - - return content_items - - def find(self) -> list[ContentItem]: - """ - Find all content items that are tagged with a descendant tag. - - Returns - ------- - list[ContentItem] - List of content items that are tagged with a descendant tag. - """ - descendant_tags = self._parent_tag.descendant_tags.find() - content_items = self._unique_content_items(descendant_tags) - return content_items - - class Tags(ContextManager): """Content item tags resource.""" @@ -329,6 +398,14 @@ def __init__(self, ctx: Context, path: str) -> None: self._ctx = ctx self._path = path + def _tag_path(self, tag_id: str) -> str: + if not isinstance(tag_id, str): + raise TypeError('Tag `"id"` must be a string') + if tag_id == "": + raise ValueError('Tag `"id"` cannot be an empty string') + + return f"{self._path}/{tag_id}" + def get(self, tag_id: str) -> Tag: """ Get a single tag by its identifier. @@ -342,17 +419,32 @@ def get(self, tag_id: str) -> Tag: ------- Tag The tag object. + + Examples + -------- + ```python + import posit + + client = posit.connect.Client() + mytag = client.tags.get("TAG_ID_HERE") + ``` """ if not isinstance(tag_id, str): raise TypeError("`tag_id` must be a string") + if tag_id == "": raise ValueError("`tag_id` cannot be an empty string") - path = f"{self._path}/{tag_id}" + path = self._tag_path(tag_id) url = self._ctx.url + path response = self._ctx.session.get(url) return Tag(self._ctx, path, **response.json()) def _update_parent_kwargs(self, kwargs: dict) -> dict: + """ + Sets the `parent_id` key in the kwargs if `parent` is provided. + + Asserts that the `parent=` and `parent_id=` keys are not both provided. + """ parent = kwargs.get("parent", None) if parent is None: # No parent to upgrade, return the kwargs as is @@ -410,7 +502,7 @@ def find(self, /, **kwargs) -> list[Tag]: ```python import posit - client = posit.connect.Client(...) + client = posit.connect.Client() # Find all tags all_tags = client.tags.find() @@ -427,10 +519,11 @@ def find(self, /, **kwargs) -> list[Tag]: kwargs, # pyright: ignore[reportArgumentType] ) url = self._ctx.url + self._path + print("barret", url, updated_kwargs) response = self._ctx.session.get(url, params=updated_kwargs) results = response.json() - return [Tag(self._ctx, f"{self._path}/{result['id']}", **result) for result in results] + return [Tag(self._ctx, self._tag_path(result["id"]), **result) for result in results] @overload def create(self, /, *, name: str) -> Tag: ... @@ -463,7 +556,7 @@ def create(self, /, **kwargs) -> Tag: ```python import posit - client = posit.connect.Client(...) + client = posit.connect.Client() mytag = client.tags.create(name="tag_name") subtag = client.tags.create(name="subtag_name", parent=mytag) @@ -476,7 +569,7 @@ def create(self, /, **kwargs) -> Tag: url = self._ctx.url + self._path response = self._ctx.session.post(url, json=updated_kwargs) result = response.json() - return Tag(self._ctx, f"{self._path}/{result['id']}", **result) + return Tag(self._ctx, self._tag_path(result["id"]), **result) class ContentItemTags(ContextManager): @@ -492,6 +585,7 @@ def __init__(self, ctx: Context, path: str, /, *, tags_path: str, content_guid: self._content_guid = content_guid + # TODO-barret; Example def find(self) -> list[Tag]: """ Find all tags that are associated with a single content item. From e5cd39cabd550f2d4e886a0b46b5c4719a5c1601 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Fri, 6 Dec 2024 10:47:54 -0500 Subject: [PATCH 14/20] Make methods singular --- src/posit/connect/tags.py | 50 +++++++++++++++++++-------------------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/src/posit/connect/tags.py b/src/posit/connect/tags.py index e97c1b2d..3e0b9b0f 100644 --- a/src/posit/connect/tags.py +++ b/src/posit/connect/tags.py @@ -611,60 +611,60 @@ def find(self) -> list[Tag]: return tags - def _to_tag_ids(self, tags: tuple[str | Tag, ...]) -> list[str]: - tag_ids: list[str] = [] - for i, tag in enumerate(tags): - tag_id = tag["id"] if isinstance(tag, Tag) else tag + def _to_tag_id(self, tag: str | Tag) -> str: + if isinstance(tag, Tag): + tag_id = tag["id"] if not isinstance(tag_id, str): - raise TypeError(f"Expected 'tags[{i}]' to be a string. Received: {tag_id}") - if tag_id == "": - raise ValueError(f"Expected 'tags[{i}]' to be non-empty. Received: {tag_id}") + raise TypeError(f'Expected `tag=` `"id"` to be a string. Received: {tag}') - tag_ids.append(tag_id) + elif isinstance(tag, str): + tag_id = tag + else: + raise TypeError(f"Expected `tag=` to be a string or Tag object. Received: {tag}") - return tag_ids + if tag_id == "": + raise ValueError(f"Expected 'tag=' ID to be non-empty. Received: {tag_id}") + + return tag_id - def add(self, *tags: str | Tag) -> None: + def add(self, tag: str | Tag) -> None: """ - Add the specified tags to an individual content item. + Add the specified tag to an individual content item. When adding a tag, all tags above the specified tag in the tag tree are also added to the content item. Parameters ---------- - tags : str | Tag - The tags id or tag object to add to the content item. + tag : str | Tag + The tag id or tag object to add to the content item. Returns ------- None """ - tag_ids = self._to_tag_ids(tags) + tag_id = self._to_tag_id(tag) url = self._ctx.url + self._path - for tag_id in tag_ids: - self._ctx.session.post(url, json={"tag_id": tag_id}) + self._ctx.session.post(url, json={"tag_id": tag_id}) - def delete(self, *tags: str | Tag) -> None: + def delete(self, tag: str | Tag) -> None: """ - Remove the specified tags from an individual content item. + Remove the specified tag from an individual content item. When removing a tag, all tags above the specified tag in the tag tree are also removed from the content item. Parameters ---------- - tags : str | Tag - The tags id or tag object to remove from the content item. + tag : str | Tag + The tag id or tag object to remove from the content item. Returns ------- None """ - tag_ids = self._to_tag_ids(tags) + tag_id = self._to_tag_id(tag) - url = self._ctx.url + self._path - for tag_id in tag_ids: - tag_url = f"{url}/{tag_id}" - self._ctx.session.delete(tag_url) + url = self._ctx.url + self._path + tag_id + self._ctx.session.delete(url) From 110eae6090b2c2ba434b8426603be8d106ee4476 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Fri, 6 Dec 2024 12:20:14 -0500 Subject: [PATCH 15/20] Add Examples --- src/posit/connect/tags.py | 49 ++++++++++++++++++++++++++++++++------- 1 file changed, 40 insertions(+), 9 deletions(-) diff --git a/src/posit/connect/tags.py b/src/posit/connect/tags.py index 3e0b9b0f..8c012b64 100644 --- a/src/posit/connect/tags.py +++ b/src/posit/connect/tags.py @@ -558,8 +558,8 @@ def create(self, /, **kwargs) -> Tag: client = posit.connect.Client() - mytag = client.tags.create(name="tag_name") - subtag = client.tags.create(name="subtag_name", parent=mytag) + category_tag = client.tags.create(name="category_name") + tag = client.tags.create(name="tag_name", parent=category_tag) ``` """ updated_kwargs = self._update_parent_kwargs( @@ -585,7 +585,6 @@ def __init__(self, ctx: Context, path: str, /, *, tags_path: str, content_guid: self._content_guid = content_guid - # TODO-barret; Example def find(self) -> list[Tag]: """ Find all tags that are associated with a single content item. @@ -594,6 +593,18 @@ def find(self) -> list[Tag]: ------- list[Tag] List of tags associated with the content item. + + Examples + -------- + ```python + import posit + + client = posit.connect.Client() + content_item = client.content.find_one() + + # Find all tags associated with the content item + content_item_tags = content_item.tags.find() + ``` """ url = self._ctx.url + self._path response = self._ctx.session.get(url) @@ -639,9 +650,19 @@ def add(self, tag: str | Tag) -> None: tag : str | Tag The tag id or tag object to add to the content item. - Returns - ------- - None + Examples + -------- + ```python + import posit + + client = posit.connect.Client() + + content_item = client.content.find_one() + tag = client.tags.find()[0] + + # Add a tag + content_item.tags.add(tag) + ``` """ tag_id = self._to_tag_id(tag) @@ -660,9 +681,19 @@ def delete(self, tag: str | Tag) -> None: tag : str | Tag The tag id or tag object to remove from the content item. - Returns - ------- - None + Examples + -------- + ```python + import posit + + client = posit.connect.Client() + + content_item = client.content.find_one() + content_item_first_tag = content_item.tags.find()[0] + + # Remove a tag + content_item.tags.delete(content_item_first_tag) + ``` """ tag_id = self._to_tag_id(tag) From ef0487a62105d0de1f3e60094b59aac5b48fa0dc Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Fri, 6 Dec 2024 12:27:53 -0500 Subject: [PATCH 16/20] Update permissions.py --- src/posit/connect/permissions.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/posit/connect/permissions.py b/src/posit/connect/permissions.py index 37f25ad2..0db29487 100644 --- a/src/posit/connect/permissions.py +++ b/src/posit/connect/permissions.py @@ -272,7 +272,6 @@ def destroy(self, permission: str | Group | User | Permission, /) -> None: permission_obj = self.find_one( principal_guid=principal_guid, ) - print("Barret!", permission, principal_guid, permission_obj) if permission_obj is None: raise ValueError(f"Permission with principal_guid '{principal_guid}' not found.") elif isinstance(permission, Permission): From 523550a58f40b7214c62dd668a53ea470f3d8858 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Fri, 6 Dec 2024 12:28:10 -0500 Subject: [PATCH 17/20] Be sure the content items are properties --- src/posit/connect/tags.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/posit/connect/tags.py b/src/posit/connect/tags.py index 8c012b64..20940827 100644 --- a/src/posit/connect/tags.py +++ b/src/posit/connect/tags.py @@ -13,6 +13,7 @@ class _RelatedTagsBase(ContextManager, ABC): + @property @abstractmethod def content_items(self) -> _TagContentItemsBase: pass @@ -204,6 +205,7 @@ def __init__(self, ctx: Context, path: str, /, *, parent_tag: Tag) -> None: self._parent_tag = parent_tag + @property def content_items(self) -> ChildTagContentItems: """ Find all content items from the child tags. @@ -319,6 +321,7 @@ def __init__(self, ctx: Context, /, *, parent_tag: Tag) -> None: self._ctx = ctx self._parent_tag = parent_tag + @property def content_items(self) -> DescendantTagContentItems: """ Find all content items from the descendant tags. From e753556d86b0eb57c6fac6e34e5ebddf9e81bf19 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Fri, 6 Dec 2024 12:28:21 -0500 Subject: [PATCH 18/20] Test content item properties --- integration/tests/posit/connect/test_tags.py | 30 +++++++++----------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/integration/tests/posit/connect/test_tags.py b/integration/tests/posit/connect/test_tags.py index 1ce8405c..9c5f67fe 100644 --- a/integration/tests/posit/connect/test_tags.py +++ b/integration/tests/posit/connect/test_tags.py @@ -1,12 +1,6 @@ -from typing import TYPE_CHECKING - from posit import connect -if TYPE_CHECKING: - from posit.connect.content import ContentItem - -# add integration tests here! class TestTags: @classmethod def setup_class(cls): @@ -146,20 +140,22 @@ def test_tag_content_items(self): assert len(tagC.content_items.find()) == 3 # Make sure unique content items are found - content_items_list: list[ContentItem] = [] - for tag in [tagA, *tagA.descendant_tags.find()]: - content_items_list.extend(tag.content_items.find()) - # Get unique content_item guids - content_item_guids = set[str]() - for content_item in content_items_list: - if content_item["guid"] not in content_item_guids: - content_item_guids.add(content_item["guid"]) - - assert content_item_guids == { + child_content_items = tagA.child_tags.content_items.find() + assert len(child_content_items) == 2 + child_content_item_guids = [content_item["guid"] for content_item in child_content_items] + assert child_content_item_guids == [self.contentB["guid"], self.contentC["guid"]] + + descendant_content_items = tagA.descendant_tags.content_items.find() + assert len(descendant_content_items) == 3 + + descendant_content_item_guids = [ + content_item["guid"] for content_item in descendant_content_items + ] + assert descendant_content_item_guids == [ self.contentA["guid"], self.contentB["guid"], self.contentC["guid"], - } + ] self.contentA.tags.delete(tagRoot) self.contentB.tags.delete(tagRoot) From ef4bc008503aae623b57e9066fa5c260344e6abc Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Fri, 6 Dec 2024 12:37:18 -0500 Subject: [PATCH 19/20] Update integration tests. Test more of tag content items --- integration/tests/posit/connect/test_tags.py | 21 ++++++++++++-------- src/posit/connect/tags.py | 1 - 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/integration/tests/posit/connect/test_tags.py b/integration/tests/posit/connect/test_tags.py index 9c5f67fe..27a0aeb5 100644 --- a/integration/tests/posit/connect/test_tags.py +++ b/integration/tests/posit/connect/test_tags.py @@ -140,22 +140,27 @@ def test_tag_content_items(self): assert len(tagC.content_items.find()) == 3 # Make sure unique content items are found - child_content_items = tagA.child_tags.content_items.find() - assert len(child_content_items) == 2 - child_content_item_guids = [content_item["guid"] for content_item in child_content_items] - assert child_content_item_guids == [self.contentB["guid"], self.contentC["guid"]] + assert len(tagB.child_tags.content_items.find()) == 0 + assert len(tagD.child_tags.content_items.find()) == 0 + assert len(tagB.descendant_tags.content_items.find()) == 0 + assert len(tagD.descendant_tags.content_items.find()) == 0 + + child_content_items = tagC.child_tags.content_items.find() + assert len(child_content_items) == 1 + child_content_item_guids = {content_item["guid"] for content_item in child_content_items} + assert child_content_item_guids == {self.contentA["guid"]} descendant_content_items = tagA.descendant_tags.content_items.find() assert len(descendant_content_items) == 3 - descendant_content_item_guids = [ + descendant_content_item_guids = { content_item["guid"] for content_item in descendant_content_items - ] - assert descendant_content_item_guids == [ + } + assert descendant_content_item_guids == { self.contentA["guid"], self.contentB["guid"], self.contentC["guid"], - ] + } self.contentA.tags.delete(tagRoot) self.contentB.tags.delete(tagRoot) diff --git a/src/posit/connect/tags.py b/src/posit/connect/tags.py index 20940827..a26e7c12 100644 --- a/src/posit/connect/tags.py +++ b/src/posit/connect/tags.py @@ -522,7 +522,6 @@ def find(self, /, **kwargs) -> list[Tag]: kwargs, # pyright: ignore[reportArgumentType] ) url = self._ctx.url + self._path - print("barret", url, updated_kwargs) response = self._ctx.session.get(url, params=updated_kwargs) results = response.json() From d705d92135ea55cfebdb5deacd3ee8e2a289c4c1 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Fri, 6 Dec 2024 15:17:59 -0500 Subject: [PATCH 20/20] Remove `.content_items` property from `ChildTags` and `DescendantTags` helper classes --- integration/tests/posit/connect/test_tags.py | 21 +-- src/posit/connect/tags.py | 141 +------------------ 2 files changed, 6 insertions(+), 156 deletions(-) diff --git a/integration/tests/posit/connect/test_tags.py b/integration/tests/posit/connect/test_tags.py index 27a0aeb5..37aae32b 100644 --- a/integration/tests/posit/connect/test_tags.py +++ b/integration/tests/posit/connect/test_tags.py @@ -140,23 +140,11 @@ def test_tag_content_items(self): assert len(tagC.content_items.find()) == 3 # Make sure unique content items are found - assert len(tagB.child_tags.content_items.find()) == 0 - assert len(tagD.child_tags.content_items.find()) == 0 - assert len(tagB.descendant_tags.content_items.find()) == 0 - assert len(tagD.descendant_tags.content_items.find()) == 0 + content_items = tagA.content_items.find() + assert len(content_items) == 3 - child_content_items = tagC.child_tags.content_items.find() - assert len(child_content_items) == 1 - child_content_item_guids = {content_item["guid"] for content_item in child_content_items} - assert child_content_item_guids == {self.contentA["guid"]} - - descendant_content_items = tagA.descendant_tags.content_items.find() - assert len(descendant_content_items) == 3 - - descendant_content_item_guids = { - content_item["guid"] for content_item in descendant_content_items - } - assert descendant_content_item_guids == { + content_item_guids = {content_item["guid"] for content_item in content_items} + assert content_item_guids == { self.contentA["guid"], self.contentB["guid"], self.contentC["guid"], @@ -169,6 +157,7 @@ def test_tag_content_items(self): assert len(tagA.content_items.find()) == 0 assert len(tagB.content_items.find()) == 0 assert len(tagC.content_items.find()) == 0 + assert len(tagD.content_items.find()) == 0 # cleanup tagRoot.destroy() diff --git a/src/posit/connect/tags.py b/src/posit/connect/tags.py index a26e7c12..659088a2 100644 --- a/src/posit/connect/tags.py +++ b/src/posit/connect/tags.py @@ -13,38 +13,11 @@ class _RelatedTagsBase(ContextManager, ABC): - @property - @abstractmethod - def content_items(self) -> _TagContentItemsBase: - pass - @abstractmethod def find(self) -> list[Tag]: pass -class _TagContentItemsBase(ContextManager, ABC): - @staticmethod - def _unique_content_items(tags: list[Tag]) -> list[ContentItem]: - content_items: list[ContentItem] = [] - content_items_seen: set[str] = set() - - for tag in tags: - tag_content_items = tag.content_items.find() - - for content_item in tag_content_items: - content_item_guid = content_item["guid"] - - if content_item_guid not in content_items_seen: - content_items.append(content_item) - content_items_seen.add(content_item_guid) - - return content_items - - @abstractmethod - def find(self) -> list[ContentItem]: ... - - class Tag(Active): """Tag resource.""" @@ -163,7 +136,7 @@ def destroy(self) -> None: self._ctx.session.delete(url) -class TagContentItems(_TagContentItemsBase): +class TagContentItems(ContextManager): def __init__(self, ctx: Context, path: str) -> None: super().__init__() self._ctx = ctx @@ -205,29 +178,6 @@ def __init__(self, ctx: Context, path: str, /, *, parent_tag: Tag) -> None: self._parent_tag = parent_tag - @property - def content_items(self) -> ChildTagContentItems: - """ - Find all content items from the child tags. - - Returns - ------- - ChildTagContentItems - Helper class that can `.find()` all content items that are tagged with a child tag. - - Examples - -------- - ```python - import posit - - client = posit.connect.Client() - mytag = client.tags.get("TAG_ID_HERE") - - tagged_content_items = mytag.child_tags.content_items.find() - ``` - """ - return ChildTagContentItems(self._ctx, self._path, parent_tag=self._parent_tag) - def find(self) -> list[Tag]: """ Find all child tags that are direct children of a single tag. @@ -252,101 +202,12 @@ def find(self) -> list[Tag]: return child_tags -class ChildTagContentItems(_TagContentItemsBase): - def __init__(self, ctx: Context, path: str, /, *, parent_tag: Tag) -> None: - super().__init__() - self._ctx = ctx - self._path = path - self._parent_tag = parent_tag - - def find(self) -> list[ContentItem]: - """ - Find all content items that are tagged with a child tag. - - Returns - ------- - list[ContentItem] - List of content items that are tagged with a child tag. - - Examples - -------- - ```python - import posit - - client = posit.connect.Client() - mytag = client.tags.get("TAG_ID_HERE") - - tagged_content_items = mytag.child_tags.content_items.find() - ``` - """ - child_tags = self._parent_tag.child_tags.find() - content_items = self._unique_content_items(child_tags) - return content_items - - -class DescendantTagContentItems(_TagContentItemsBase): - def __init__(self, ctx: Context, /, *, parent_tag: Tag) -> None: - super().__init__() - self._ctx = ctx - self._parent_tag = parent_tag - - def find(self) -> list[ContentItem]: - """ - Find all content items that are tagged with a descendant tag. - - Returns - ------- - list[ContentItem] - List of content items that are tagged with a descendant tag. - - Examples - -------- - ```python - import posit - - client = posit.connect.Client() - mytag = client.tags.get("TAG_ID_HERE") - - tagged_content_items = mytag.descendant_tags.content_items.find() - ``` - """ - descendant_tags = self._parent_tag.descendant_tags.find() - content_items = self._unique_content_items(descendant_tags) - return content_items - - class DescendantTags(_RelatedTagsBase): def __init__(self, ctx: Context, /, *, parent_tag: Tag) -> None: super().__init__() self._ctx = ctx self._parent_tag = parent_tag - @property - def content_items(self) -> DescendantTagContentItems: - """ - Find all content items from the descendant tags. - - Returns - ------- - DescendantTagContentItems - Helper class that can `.find()` all content items that are tagged with a descendant tag. - - Examples - -------- - ```python - import posit - - client = posit.connect.Client() - mytag = client.tags.find(id="TAG_ID_HERE") - - tagged_content_items = mytag.descendant_tags.content_items.find() - ``` - """ - return DescendantTagContentItems( - self._ctx, - parent_tag=self._parent_tag, - ) - def find(self) -> list[Tag]: """ Find all child tags that descend from a single tag.