Skip to content

Commit

Permalink
feat: adds support for backwards and forward compatability
Browse files Browse the repository at this point in the history
  • Loading branch information
tdstein committed Mar 14, 2024
1 parent 7ba9950 commit 7567bbf
Show file tree
Hide file tree
Showing 6 changed files with 219 additions and 39 deletions.
16 changes: 13 additions & 3 deletions src/posit/connect/content.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,11 @@
from . import urls

from .config import Config
from .resources import Resource, Resources


@dataclass
class ContentItem:
@dataclass(init=False)
class ContentItem(Resource):
guid: str
name: str
title: Optional[str]
Expand Down Expand Up @@ -57,7 +58,7 @@ class ContentItem:
id: str


class Content:
class Content(Resources[ContentItem]):
def __init__(self, config: Config, session: Session) -> None:
self.url = urls.append_path(config.url, "v1/content")
self.config = config
Expand All @@ -83,3 +84,12 @@ def get(self, id: str) -> ContentItem:
url = urls.append_path(self.url, id)
response = self.session.get(url)
return ContentItem(**response.json())

def create(self) -> ContentItem:
raise NotImplementedError()

def update(self) -> ContentItem:
raise NotImplementedError()

def delete(self) -> None:
raise NotImplementedError()
49 changes: 49 additions & 0 deletions src/posit/connect/resources.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
from abc import ABC, abstractmethod
from dataclasses import asdict, dataclass, fields
from typing import Generic, List, Optional, TypeVar


T = TypeVar("T")


@dataclass
class Resource(ABC):
def __init__(self, **kwargs) -> None:
mapping = {self._compatibility.get(k, k): v for k, v in kwargs.items()}
for prop in fields(self):
setattr(
self, prop.name, mapping.get(prop.name, kwargs.get(prop.name, None))
)

@property
def _compatibility(self):
return {}

def asdict(self) -> dict:
return asdict(self)


class Resources(ABC, Generic[T]):
@abstractmethod
def create(self, *args, **kwargs) -> T:
raise NotImplementedError()

@abstractmethod
def delete(self, *args, **kwargs) -> None:
raise NotImplementedError()

@abstractmethod
def find(self, *args, **kwargs) -> List[T]:
raise NotImplementedError()

@abstractmethod
def find_one(self, *args, **kwargs) -> Optional[T]:
raise NotImplementedError()

@abstractmethod
def get(self, *args, **kwargs) -> T:
raise NotImplementedError()

@abstractmethod
def update(self, *args, **kwargs) -> T:
raise NotImplementedError()
61 changes: 36 additions & 25 deletions src/posit/connect/users.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
from __future__ import annotations

from dataclasses import dataclass, asdict
from dataclasses import dataclass
from datetime import datetime
from typing import Callable, List, Optional
from typing import Callable, List

from requests import Session


from . import urls

from .config import Config
from .paginator import _MAX_PAGE_SIZE, Paginator
from .resources import Resources, Resource


@dataclass
class User:
@dataclass(init=False)
class User(Resource):
guid: str
email: str
username: str
Expand All @@ -24,37 +26,37 @@ class User:
updated_time: datetime
active_time: datetime
confirmed: bool
is_locked: bool

asdf: Optional[bool]
locked: bool

# A local shim around locked. This field does not exist in the Connect API.
is_locked: bool

@property
def _compatibility(self):
return {"locked": "is_locked"}

@property # type: ignore
def locked(self):
from warnings import warn

warn("this is a deprecation notice", DeprecationWarning)
warn(
"the field 'locked' will be removed in the next major release",
FutureWarning,
)
return self.is_locked

@locked.setter
def locked(self, value):
self.locked = value

@classmethod
def from_dict(cls, instance: dict) -> User:
field_names = {"locked": "is_locked"}
instance = {field_names.get(k, k): v for k, v in instance.items()}
return cls(**instance)
from warnings import warn

def asdict(self) -> dict:
field_names = {"is_locked": "locked"}
return {
**asdict(self),
**{field_names.get(k, k): v for k, v in asdict(self).items()},
}
warn(
"the field 'locked' will be removed in the next major release",
FutureWarning,
)
self.is_locked = value


class Users:
class Users(Resources[User]):
def __init__(self, config: Config, session: Session) -> None:
self.url = urls.append_path(config.url, "v1/users")
self.config = config
Expand All @@ -64,7 +66,7 @@ def find(
self, filter: Callable[[User], bool] = lambda _: True, page_size=_MAX_PAGE_SIZE
) -> List[User]:
results = Paginator(self.session, self.url, page_size=page_size).get_all()
users = (User.from_dict(result) for result in results)
users = (User(**result) for result in results)
return [user for user in users if filter(user)]

def find_one(
Expand All @@ -74,12 +76,21 @@ def find_one(
while pager.total is None or pager.seen < pager.total:
result = pager.get_next_page()
for u in result:
user = User.from_dict(u)
user = User(**u)
if filter(user):
return user
return None

def get(self, id: str) -> User:
url = urls.append_path(self.url, id)
response = self.session.get(url)
return User.from_dict(response.json())
return User(**response.json())

def create(self) -> User:
raise NotImplementedError()

def update(self) -> User:
raise NotImplementedError()

def delete(self) -> None:
raise NotImplementedError()
115 changes: 115 additions & 0 deletions tests/posit/connect/test_resources.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import pytest

from dataclasses import dataclass
from typing import Any, List, Optional

from posit.connect.resources import Resources, Resource


@dataclass(init=False)
class FakeResource(Resource):
foo: str
bar: str

@property
def _compatibility(self):
return {"backwards": "foo"}

@property
def backwards(self):
"""A field which was removed from the API.
This property provides backwards compatibility when a Connect is upgraded to a newer version which no longer provides "backwards".
"""
return self.foo


class TestResource:
def test_init(self):
r = FakeResource(foo="foo", bar="bar")
assert r.foo == "foo"
assert r.bar == "bar"

def test_from_dict(self):
o = {"foo": "foo", "bar": "bar"}
r = FakeResource(**o)
assert r.foo == "foo"
assert r.bar == "bar"

def test_from_dict_with_missing_fields(self):
o = {"bar": "bar"}
r = FakeResource(**o)
assert r.foo is None
assert r.bar == "bar"

def test_from_dict_with_additional_fields(self):
o = {"foo": "foo", "bar": "bar", "baz": "baz"}
r = FakeResource(**o)
assert r.foo == "foo"

def test_forwards_compatibility(self):
o = {"foo": "foo", "bar": "bar"}
r = FakeResource(**o)
assert r.backwards == "foo"
assert r.bar == "bar"

def test_client_backwards_compatibility(self):
o = {"foo": "foo", "bar": "bar"}
r = FakeResource(**o)
assert r.foo == "foo"
assert r.bar == "bar"
assert r.backwards == "foo"

def test_server_backwards_compatibility(self):
"""Asserts backwards compatibility for changes on the server.
This checks that client code written for a current version of Connect can continue to function with previous versions of Connect when we implement backwards compatibility.
"""
o = {"bar": "bar", "backwards": "backwards"}
r = FakeResource(**o)
assert r.foo == "backwards"
assert r.backwards == "backwards"


class TestResources(Resources[Any]):
def create(self) -> Any:
return super().create() # type: ignore [safe-super]

def delete(self) -> None:
return super().delete() # type: ignore [safe-super]

def find(self) -> List[Any]:
return super().find() # type: ignore [safe-super]

def find_one(self) -> Optional[Any]:
return super().find_one() # type: ignore [safe-super]

def get(self) -> Any:
return super().get() # type: ignore [safe-super]

def update(self) -> Any:
return super().update() # type: ignore [safe-super]

def test_create(self):
with pytest.raises(NotImplementedError):
self.create()

def test_delete(self):
with pytest.raises(NotImplementedError):
self.delete()

def test_find(self):
with pytest.raises(NotImplementedError):
self.find()

def test_find_one(self):
with pytest.raises(NotImplementedError):
self.find_one()

def test_get(self):
with pytest.raises(NotImplementedError):
self.get()

def test_update(self):
with pytest.raises(NotImplementedError):
self.update()
2 changes: 1 addition & 1 deletion tests/posit/connect/test_users.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ def test_users_find(self):
"updated_time",
"active_time",
"confirmed",
"locked",
"is_locked",
]
assert df.username.to_list() == ["al", "robert", "carlos12"]

Expand Down
15 changes: 5 additions & 10 deletions tinkering.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,9 @@
import pandas

from posit.connect import Client

with Client() as client:
user = client.users.get("f55ca95d-ce52-43ed-b31b-48dc4a07fe13")
print(user.locked)
print(user.is_locked)

users = client.users.find()

# This method of conversion provides forward compatibility against the API as fields are removed. This would require
import pandas
print(pandas.DataFrame(client.users.find()))
print(pandas.DataFrame(client.content.find()))

users = (user.asdict() for user in users)
print(pandas.DataFrame(users))
print(client.me.asdict())

0 comments on commit 7567bbf

Please sign in to comment.