-
Notifications
You must be signed in to change notification settings - Fork 4
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Expected usage for Resource objects that need custom methods? #23
Comments
Right, of course you can create any methods that make sense for each object type. I put in the readme "Entities have methods that are appropriate to them" but if it would help clarify, please make a PR to expand on that and add examples. |
Oh sorry, that part of my question wasn’t clear. “TypedDict classes can contain only type annotations” |
Ah ok. Then it sounds like |
I've been going back and forth between |
Poking at this some more,
|
This is the caveat that led me down the TypedDict path. I'm satisfied as long as we can support conversion from |
I think that's the easy part. So let's summarize the alternatives: ❌ subclass |
I think from dataclasses import dataclass
from typing import List, Optional, Union
from unittest.mock import MagicMock
import requests
@dataclass
class FoobarV2024_02_0:
field1: str
field2: Optional[str]
@dataclass
class FoobarV2023_01_1:
field1: str
class FoobarConverter:
def __init__(self, version="2024.02.0") -> None:
self.version = version
def convert(self, instance: dict) -> Union[FoobarV2024_02_0, FoobarV2023_01_1]:
if self.version >= "2024.02.0":
return FoobarV2024_02_0(**instance)
if self.version >= "2023.01.1":
return FoobarV2023_01_1(**instance)
class Foobars:
def __init__(self, version: str) -> None:
self.converter = FoobarConverter(version)
def find(self) -> List[Union[FoobarV2024_02_0, FoobarV2023_01_1]]:
response: requests.Response = MagicMock()
response.json.return_value = [
{"field1": "asdf", "field2": None},
{"field1": "asdf", "field2": None},
]
return [self.converter.convert(instance) for instance in response.json()]
foobars = Foobars("2024.03.1")
items = foobars.find()
for item in items:
print(item)
foobars = Foobars("2023.12.0")
items = foobars.find()
for item in items:
print(item) Running this script results in the following. The Traceback here is what we want since the mocked API is returning We would want to provide a better user error, but hopefully you get the idea... Running version 2024.03.1...
FoobarV2024_02_0(field1='asdf', field2=None)
FoobarV2024_02_0(field1='asdf', field2=None)
Running version 2023.12.0...
Traceback (most recent call last):
File "/Users/me/Code/posit-sdk-py/src/posit/connect/foobars.py", line 53, in <module>
items = foobars.find()
^^^^^^^^^^^^^^
File "/Users/me/Code/posit-sdk-py/src/posit/connect/foobars.py", line 41, in find
return [self.converter.convert(instance) for instance in response.json()]
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/me/Code/posit-sdk-py/src/posit/connect/foobars.py", line 28, in convert
return FoobarV2023_01_1(**instance)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
TypeError: FoobarV2023_01_1.__init__() got an unexpected keyword argument 'field2' |
Let me try to summarize where we are right now. We're exploring two approaches right now: dataclass (#91, #92, #95) and dict (#94). Requirements we want to satisfy:
It looks like dataclass can meet these requirements, from what I see. I did a quick benchmark of ContentItem using dataclass vs. with dict, and the difference was on the order of 10ms when pulling the 5k items from our internal Connect server. There's one other thing I can think of that I think should be a requirement, but I'm not 100% sure how to articulate it. I think we don't want to allow updating attributes of objects. As in,
I think we don't want to have the local object getting mutated without it being reflected on the server. But we don't want every setattr to trigger a PATCH request--that may be surprising, and would be expensive if you're updating multiple attributes. I think it is safer to have the single |
One other thought: perhaps we also keep all of the timestamp fields in objects as strings, and let users deal with parsing them if/when they need. Aside from saving the parsing cost, we wash our hands of local time zones and all of that, let users decide how they want to interpret the timestamps based on what they want to do with them. |
I've also poked at a third path: inheriting from class Resource(Mapping):
def __init__(
self, data: dict, session: Optional[Session] = None, url: Optional[str] = None
):
super().__init__()
super().__setattr__("_data", data)
super().__setattr__("session", session)
super().__setattr__("url", url)
def __getitem__(self, key):
return self._data[key]
def keys(self):
return self._data.keys()
def __contains__(self, key):
return key in self._data
def __dict__(self):
return self._data
def __iter__(self) -> Iterator:
return self._data.__iter__()
def __len__(self):
return len(self._data)
def __setattr__(self, name: str, value: Any) -> None:
raise AttributeError("Cannot set attributes: use update() instead.")
def update(self, **kwargs):
self._data.update(kwargs)
self.session.patch(self.url, json=kwargs) Advantage of this over inheriting from Disadvantage: pandas alphabetically sorts the column names unless the objects inherit from dict or dataclass. I don't think this quirk should necessarily dictate our decisions, and we can write a custom |
Added my thoughts here: #96 It seemed a bit long to append to this discussion as ana additional comment. |
Solved by #94 among others |
I see in the readme that we can operate on Resources using the methods defined by the base Resource classes, like
find()
,find_one()
,get()
,delete()
etc. What should the behavior be for resources where we want to add custom methods that don't fit into one of the standard CRUD resource operations.For example:
edit:
I could see this also being relevant for an Rmd that I wanted to re-render. e.g.
The text was updated successfully, but these errors were encountered: