Skip to content

Commit 788938c

Browse files
committed
Update readme
1 parent d2811f6 commit 788938c

File tree

6 files changed

+178
-89
lines changed

6 files changed

+178
-89
lines changed

src/posit/connect/README.md

Lines changed: 163 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,14 @@
22

33
> Note: this is design-by-wishful-thinking, not how things actually work today.
44
> To discuss or propose changes, open a PR suggesting new language.
5+
56
### Connecting
67

78
To get started, import the Connect `Client` and create a connection. You can specify the `endpoint` for your Connect server URL and your `api_key`; if not specified, they'll be pulled from the environment (`CONNECT_SERVER` and `CONNECT_API_KEY`).
89

910
It is expected that `Client()` just works from within any Posit product's environment (Workbench, Connect, etc.), either by API key and prior system configuration, or by some means of identity federation.
1011

11-
```
12+
```python
1213
from posit.connect import Client
1314

1415
con = Client()
@@ -18,31 +19,31 @@ con = Client()
1819

1920
Many resources in the SDK refer to *collections* of *entities* or records in Connect.
2021

21-
All of the general collections can be referenced as properties of the Client object (e.g. `client.content`, `client.users`). Some collections belong to a single entity and are referenced from them similarly (e.g. `content_item.permissions`).
22+
All of the general collections can be referenced as properties of the Client object (e.g. `client.content`, `client.users`). Some collections belong to a single entity and are referenced from them similarly (e.g. `content_item.permissions`).
2223

2324
All collections are iterable objects with all read-only List-like methods implemented. They also have the following methods:
2425

25-
* `.find()`: returns another iterable collection object.
26-
* Calling `.find()` with no arguments retrieves all available entities
27-
* If no entities match the query, `.find()` returns a length-0 collection.
28-
* Iterating over a collection without having first called `find()` is equivalent to having queried for all.
29-
* `find()` should use query-based REST APIs where existing, and fall back to retrieving all and filtering client-side where those APIs do not (yet) exist.
30-
* Should `collection.find().find()` work? Probably.
26+
* `.find()`: returns another iterable collection object.
27+
* Calling `.find()` with no arguments retrieves all available entities
28+
* If no entities match the query, `.find()` returns a length-0 collection.
29+
* Iterating over a collection without having first called `find()` is equivalent to having queried for all.
30+
* `find()` should use query-based REST APIs where existing, and fall back to retrieving all and filtering client-side where those APIs do not (yet) exist.
31+
* Should `collection.find().find()` work? Probably.
3132
* `.get(guid)` method that returns a single entity by id. If one is not found, it raises `NotFoundError`
3233
* `.find_one()` is a convenience method that queries with `.find()` and returns a single entity
33-
* If more than one entity match the query, `.find_one()` returns the first
34-
* If no entities match, `.find_one()` returns `None`
35-
* If you need stricter behavior (e.g. you want to be sure that one and only one entity are returned by your query), use `.find()` or `.get()`.
36-
* `.to_pandas()` materializes the collection in a pandas `DataFrame`.
37-
* pandas is not a required dependency of the SDK. `.to_pandas()` should try to import inside the method.
34+
* If more than one entity match the query, `.find_one()` returns the first
35+
* If no entities match, `.find_one()` returns `None`
36+
* If you need stricter behavior (e.g. you want to be sure that one and only one entity are returned by your query), use `.find()` or `.get()`.
37+
* `.to_pandas()` materializes the collection in a pandas `DataFrame`.
38+
* pandas is not a required dependency of the SDK. `.to_pandas()` should try to import inside the method.
3839

39-
The `.find()` and `.find_one()` methods use named arguments rather than accepting a dict so that IDE tab completion can work.
40+
The `.find()` and `.find_one()` methods use named arguments rather than accepting a dict so that IDE tab completion can work.
4041

41-
Collections should handle all API reponse pagination invisibly so that the Python user doesn't need to worry about pages.
42+
Collections should handle all API reponse pagination invisibly so that the Python user doesn't need to worry about pages.
4243

43-
Entities have methods that are appropriate to them. Fields in the entity bodies can be accessed as properties.
44+
Entities have methods that are appropriate to them. Fields in the entity bodies can be accessed as properties.
4445

45-
```
46+
```python
4647
for st in con.content.find(app_mode="streamlit"):
4748
print(st.title)
4849

@@ -53,30 +54,168 @@ for perm in my_app.permissions:
5354

5455
### Mapping to HTTP request methods
5556

56-
Entities have an `.update()` method that maps to a `PATCH` request. `.delete()` is `DELETE`.
57+
Entities have an `.update()` method that maps to a `PATCH` request. `.delete()` is `DELETE`.
5758

58-
```
59+
```python
5960
my_app.update(title="Quarterly Analysis of Team Velocity")
6061
my_app.permissions.find_one(email="[email protected]").update(role="owner")
6162
my_app.permissions.find_one(email="[email protected]").delete()
6263
```
6364

6465
Collections have a `.create()` method that maps to `POST` to create a new entity. It may be aliased to other verbs as appropriate for the entity.
6566

66-
```
67+
```python
6768
my_app.permissions.add(email="[email protected]", role="viewer")
6869
```
6970

7071
### Field/attribute naming
7172

72-
The Python SDK should present the interface we wish we had, and we can evolve the REST API to match that over time. It is the adapter layer that allows us to evolve the Connect API more freely.
73+
The Python SDK should present the interface we wish we had, and we can evolve the REST API to match that over time. It is the adapter layer that allows us to evolve the Connect API more freely.
7374

74-
Naming of fields and arguments in collection and entity methods should be standardized across entity types for consistency, even if this creates a gap between our current REST API specification.
75+
Naming of fields and arguments in collection and entity methods should be standardized across entity types for consistency, even if this creates a gap between our current REST API specification.
7576

76-
As a result, the SDK takes on the burden of smoothing over the changes in the Connect API over time. Each collection and entity class may need its own adapter methods that take the current Python SDK field names and maps to the values for the version of the Connect server being used when passing to the HTTP methods.
77+
As a result, the SDK takes on the burden of smoothing over the changes in the Connect API over time. Each collection and entity class may need its own adapter methods that take the current Python SDK field names and maps to the values for the version of the Connect server being used when passing to the HTTP methods.
7778

7879
Entity `.to_dict()` methods likewise present the names and values in the Python interface, which may not map to the actual HTTP response body JSON. There should be some other way to access the raw response body.
7980

8081
### Lower-level HTTP interface
8182

82-
The client object has `.get`, `.post`, etc. methods that pass arguments through to the `requests` methods, accepting URL paths relative to the API root and including the necessary authorization. These are invoked inside the collection and entity action methods, and they are also available for users to call directly, whether because there are API resources we haven't wrapped in Pythonic methods yet, or because they are simpler RPC-style endpoints that just need to be hit directly.
83+
The client object has `.get`, `.post`, etc. methods that pass arguments through to the `requests` methods, accepting URL paths relative to the API root and including the necessary authorization. These are invoked inside the collection and entity action methods, and they are also available for users to call directly, whether because there are API resources we haven't wrapped in Pythonic methods yet, or because they are simpler RPC-style endpoints that just need to be hit directly.
84+
85+
### Constructing classes
86+
87+
Classes that contain dictionary-like data should inherit from `ReadOnlyDict` (or one of its subsclasses) and classes that contain list-like data should inherit from `ReadOnlySequence` (or one of its subsclasses).
88+
89+
#### Classes
90+
91+
`ReadOnlyDict` was created to provide a non-interactive interface to the data being returned for the class. This way users can not set any values without going through the API. By extension, any method that would change the data should return a new instance with the updated data. E.g. `.update()` methods should return a instance. The same applies for `ReadOnlySequence` classes.
92+
93+
When retrieving objects from the server, it should be retrieved through a `@property` method. This way, the data is only retrieved when it is needed. This is especially important for list-like objects.
94+
95+
```python
96+
class ContentItem(..., ContentItemActiveDict):
97+
...
98+
99+
@property
100+
def repository(self) -> ContentItemRepository | None:
101+
try:
102+
return ContentItemRepository(self._ctx)
103+
except ClientError:
104+
return None
105+
106+
...
107+
```
108+
109+
To avoid confusion between api exploration and internal values, all internal values should be prefixed with an underscore. This way, users can easily see what is part of the API and what is part of the internal workings of the class.
110+
111+
Attempt to minimize the number of locations where the same intended `path` is defined. Preferably only at `._path` (so it works with `ApiCallMixin`).
112+
113+
```python
114+
class Bundles(ApiCallMixin, ContextP[ContentItemContext]):
115+
def __init__(
116+
self,
117+
ctx: ContentItemContext,
118+
) -> None:
119+
super().__init__()
120+
self._ctx = ctx
121+
self._path = f"v1/content/{ctx.content_guid}/bundles"
122+
...
123+
```
124+
125+
#### Context
126+
127+
* `Context` - A convenience class that holds information that can be passed down to child classes.
128+
* Contains the request `.session` and `.url` information for easy API calls.
129+
* By inheriting from `Context`, it can be extended to contain more information (e.g. `ContentItemContext` adds `.content_path` and `.content_guid` to remove the requirement of passing through `content_guid` as a parameter).
130+
* These classes help prevent an explosion of parameters being passed through the classes.
131+
* `ContextP` - Protocol class that defines the attributes that a Context class should have.
132+
* `ContextT` - Type variable that defines the type of the Context class.
133+
* `ApiCallMixin` - Mixin class that provides helper methods for API calls and parsing the JSON repsonse. (e.g. `._get_api()`)
134+
* It requires `._path: str` to be defined on the instance.
135+
136+
#### Ex: Content Item helper classes
137+
138+
These example classes show how the Entity and Context classes can be extended to provide helper classes for classes related to `ContentItem`.
139+
140+
* `ContentItemP` - Extends `ContextP` with context class set to `ContentItemContext`.
141+
* `ContentItemContext` - Extends `Context` by including `content_path` and `content_guid` attributes.
142+
* `ContentItemResourceDict` - Extends `ResourceDict` with context class set to `ContentItemContext`.
143+
* `ContentItemActiveDict` - Extends `ActiveDict` with context class set to `ContentItemContext`.
144+
145+
#### Entity Classes
146+
147+
All entity classes are populated on initialization.
148+
149+
* `ReadOnlyDict` - A class that provides a read-only dictionary interface to the data.
150+
* Immutable dictionary-like object that can be iterated over.
151+
* `ResourceDict` - Extends `ReadOnlyDict`, but is aware of `._ctx: ContextT`.
152+
* `ActiveDict` - Extends `ResourceDict`, but is aware of the API calls (`ApiCallMixin`) that can be made on the data.
153+
154+
Example: `Bundle` class's init method
155+
156+
```python
157+
class BundleContext(ContentItemContext):
158+
bundle_id: str
159+
160+
def __init__(self, ctx: ContentItemContext, /, *, bundle_id: str) -> None:
161+
super().__init__(ctx, content_guid=ctx.content_guid)
162+
self.bundle_id = bundle_id
163+
164+
class Bundle(ApiDictEndpoint[BundleContext]):
165+
def __init__(self, ctx: ContentItemContext, /, **kwargs) -> None:
166+
bundle_id = kwargs.get("id")
167+
assert isinstance(bundle_id, str), f"Bundle 'id' must be a string. Got: {id}"
168+
assert bundle_id, "Bundle 'id' must not be an empty string."
169+
170+
bundle_ctx = BundleContext(ctx, bundle_id=bundle_id)
171+
path = f"v1/content/{ctx.content_guid}/bundles/{bundle_id}"
172+
get_data = len(kwargs) == 1 # `id` is required
173+
super().__init__(bundle_ctx, path, get_data, **kwargs)
174+
...
175+
```
176+
177+
When possible `**kwargs` should be typed with `**kwargs: Unpack[_Attrs]` where `_Attrs` is a class that defines the attributes that can be passed to the class. (Please define the attribute class within the usage class and have its name start with a `_`) By using `Unpack` and `**kwargs`, it allows for future new/conflicting parameters can be type ignored by the caller, but they will be sent through in the implementation.
178+
179+
Example:
180+
181+
```python
182+
class Association(ResourceDict):
183+
class _Attrs(TypedDict, total=False):
184+
app_guid: str
185+
"""The unique identifier of the content item."""
186+
oauth_integration_guid: str
187+
"""The unique identifier of an existing OAuth integration."""
188+
oauth_integration_name: str
189+
"""A descriptive name that identifies the OAuth integration."""
190+
oauth_integration_description: str
191+
"""A brief text that describes the OAuth integration."""
192+
oauth_integration_template: str
193+
"""The template used to configure this OAuth integration."""
194+
created_time: str
195+
"""The timestamp (RFC3339) indicating when this association was created."""
196+
197+
def __init__(self, ctx: Context, /, **kwargs: Unpack["Association._Attrs"]) -> None:
198+
super().__init__(ctx, **kwargs)
199+
```
200+
201+
#### Collection classes
202+
203+
* `ReadOnlySequence` - A class that provides a read-only list interface to the data.
204+
* Immutable list-like object that can be iterated over.
205+
* `ResourceSequence` - Extends `ReadOnlySequence`, but is aware of `._ctx: ContextT`.
206+
* Wants data to immediately exist in the class.
207+
* `ActiveSequence` - Extends `ResourceSequence`, but is aware of the API calls that can be made on the data. It requires `._path`
208+
* Requires `._create_instance(path: str, **kwars: Any) -> ResourceDictT` method to be implemented.
209+
* During initialization, if the data is not provided, it will be fetched from the API. (...unless `get_data=False` is passed as a parameter)
210+
* `ActiveFinderSequence` - Extends `ActiveSequence` with `.find()` and `.find_by()` methods.
211+
212+
For Collections classes, if no data is to be maintained, the class should inherit from `ContextP[CONTEXT_CLASS]`. This will help pass through the `._ctx` to children objects. If API calls are needed, it can also inherit from `ApiCallMixin` to get access to its conveniece methods (e.g. `._get_api()` which returns a parsed json result).
213+
214+
215+
When making a new class,
216+
* Use a class to define the parameters and their types
217+
* If attaching the type info class to the parent class, start with `_`. E.g.: `ContentItemRepository._Attrs`
218+
* Document all attributes like normal
219+
* When the time comes that there are multiple attribute types, we can use overloads with full documentation and unpacking of type info class for each overload method.
220+
* Inherit from `ApiDictEndpoint` or `ApiListEndpoint` as needed
221+
* Init signature should be `def __init__(self, ctx: Context, path: str, /, **attrs: Jsonifiable) -> None:`

src/posit/connect/_active.py

Lines changed: 9 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
# ################################
2+
# Design Notes
3+
#
4+
# Please see the design notes in `src/posit/connect/README.md` for example usages.
5+
# ################################
6+
17
from __future__ import annotations
28

39
import posixpath
@@ -19,27 +25,6 @@
1925
from ._json import Jsonifiable, JsonifiableList, ResponseAttrs
2026
from ._types_context import ContextT
2127

22-
# Design Notes:
23-
# * Perform API calls on property retrieval. e.g. `my_content.repository`
24-
# * Dictionary endpoints: Retrieve all attributes during init unless provided
25-
# * List endpoints: Do not retrieve until `.fetch()` is called directly. Avoids cache invalidation issues.
26-
# * While slower, all ApiListEndpoint helper methods should `.fetch()` on demand.
27-
# * Only expose methods needed for `ReadOnlyDict`.
28-
# * Ex: When inheriting from `dict`, we'd need to shut down `update`, `pop`, etc.
29-
# * Use `ApiContextProtocol` to ensure that the class has the necessary attributes for API calls.
30-
# * Inherit from `ApiCallMixin` to add all helper methods for API calls.
31-
# * Classes should write the `path` only once within its init method.
32-
# * Through regular interactions, the path should only be written once.
33-
34-
# When making a new class,
35-
# * Use a class to define the parameters and their types
36-
# * If attaching the type info class to the parent class, start with `_`. E.g.: `ContentItemRepository._Attrs`
37-
# * Document all attributes like normal
38-
# * When the time comes that there are multiple attribute types, we can use overloads with full documentation and unpacking of type info class for each overload method.
39-
# * Inherit from `ApiDictEndpoint` or `ApiListEndpoint` as needed
40-
# * Init signature should be `def __init__(self, ctx: Context, path: str, /, **attrs: Jsonifiable) -> None:`
41-
42-
4328
ReadOnlyDictT = TypeVar("ReadOnlyDictT", bound="ReadOnlyDict")
4429
"""A type variable that is bound to the `Active` class"""
4530
ResourceDictT = TypeVar("ResourceDictT", bound="ResourceDict")
@@ -85,6 +70,9 @@ def __delitem__(self, key: str) -> None:
8570
"To retrieve updated values, please retrieve the parent object again."
8671
)
8772

73+
# * Only expose methods needed for `ReadOnlyDict`.
74+
# * Ex: If inheriting from `dict`, we would need to shut down `update`, `pop`, etc.
75+
8876
def __len__(self) -> int:
8977
return self._dict.__len__()
9078

@@ -148,17 +136,6 @@ class ActiveDict(ApiCallMixin, ResourceDict[ContextT]):
148136
_path: str
149137
"""The HTTP path component for the resource endpoint."""
150138

151-
# def _get_api(
152-
# self,
153-
# *path,
154-
# params: Optional[dict[str, object]] = None,
155-
# ) -> Any | None:
156-
# result: Jsonifiable = super()._get_api(*path, params=params)
157-
# if result is None:
158-
# return None
159-
# assert isinstance(result, dict), f"Expected dict from server, got {type(result)}"
160-
# return result
161-
162139
def __init__(
163140
self,
164141
ctx: ContextT,
@@ -259,12 +236,6 @@ def __ne__(self, other: object) -> bool:
259236
return NotImplemented
260237
return self._data != other._data
261238

262-
# def count(self, value: object) -> int:
263-
# return self._data.count(value)
264-
265-
# def index(self, value: object, start: int = 0, stop: int = 9223372036854775807) -> int:
266-
# return self._data.index(value, start, stop)
267-
268239
def __setitem__(self, key: int, value: Any) -> None:
269240
raise NotImplementedError(
270241
"Values are locked. "
@@ -377,27 +348,6 @@ def _get_data(self) -> Generator[ResourceDictT, None, None]:
377348
results_list = cast(JsonifiableList, results)
378349
return (self._to_instance(result) for result in results_list)
379350

380-
# @overload
381-
# def __getitem__(self, index: int) -> T: ...
382-
383-
# @overload
384-
# def __getitem__(self, index: slice) -> tuple[T, ...]: ...
385-
386-
# def __getitem__(self, index):
387-
# return self[index]
388-
389-
# def __len__(self) -> int:
390-
# return len(self._data)
391-
392-
# def __iter__(self):
393-
# return iter(self._data)
394-
395-
# def __str__(self) -> str:
396-
# return str(self._data)
397-
398-
# def __repr__(self) -> str:
399-
# return repr(self._data)
400-
401351

402352
class ActiveFinderSequence(ActiveSequence[ResourceDictT, ContextT]):
403353
"""Finder methods.

src/posit/connect/_content_repository.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from .context import Context
1212

1313

14-
class ContentItemRepository(ActiveDict):
14+
class ContentItemRepository(ActiveDict[ContentItemContext]):
1515
"""
1616
Content items GitHub repository information.
1717

src/posit/connect/oauth/associations.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ class _Attrs(TypedDict, total=False):
3232
created_time: str
3333
"""The timestamp (RFC3339) indicating when this association was created."""
3434

35-
def __init__(self, ctx: Context, /, **kwargs: Unpack[_Attrs]) -> None:
35+
def __init__(self, ctx: Context, /, **kwargs: Unpack["Association._Attrs"]) -> None:
3636
super().__init__(ctx, **kwargs)
3737

3838

0 commit comments

Comments
 (0)