Skip to content
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

refactor: Merge Active and Api code together #330

Draft
wants to merge 55 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
e14ff63
move active and api code together to work on
schloerke Nov 12, 2024
9bc8701
move up readonlydict
schloerke Nov 12, 2024
194f07b
merge Active and ApiDict class into ActiveDict
schloerke Nov 12, 2024
c5088a9
Remove `_api.py` file
schloerke Nov 12, 2024
1b1f20e
Store data in `_dict` not `_attrs`. Clean up wording
schloerke Nov 12, 2024
69bfa82
Add in `ResourceDict` class
schloerke Nov 12, 2024
7efb8fa
Update test_api_endpoint.py
schloerke Nov 12, 2024
5998b15
Implement Variant with ResourceDict
schloerke Nov 12, 2024
c91b3d1
Move content item repository to its own file
schloerke Nov 12, 2024
3089b87
Relax type restrictions (explicitly)
schloerke Nov 12, 2024
85f648c
Create `_types` file to hold protocol classes
schloerke Nov 12, 2024
57a7da9
Rename file to private
schloerke Nov 12, 2024
a3263af
Use helper classes for ContentItem and Context
schloerke Nov 13, 2024
dbfbc33
Update _content_repository.py
schloerke Nov 13, 2024
47ec1e3
Update Vanity
schloerke Nov 13, 2024
a1ba953
Cosmetic to Users
schloerke Nov 13, 2024
81ebea9
Helper method
schloerke Nov 13, 2024
df1180c
Job to inherit from ActiveDict
schloerke Nov 13, 2024
45660cf
BundleMetadata
schloerke Nov 13, 2024
8dd6d0b
Overhaul ContentItem
schloerke Nov 13, 2024
bc31ad2
Merge branch 'main' into schloerke/324-merge-api-and-resource
schloerke Nov 13, 2024
b66dc00
Merge branch 'schloerke/324-merge-api-and-resource' into schloerke/me…
schloerke Nov 13, 2024
b525358
post merge updates; Update packages
schloerke Nov 13, 2024
137ba8b
Use `hasattr()` instead of `self.__dict__`
schloerke Nov 13, 2024
f70ebaa
When updating within a ContentItem, use the fully returned result
schloerke Nov 13, 2024
b7ea2fb
Make methods public within module
schloerke Nov 14, 2024
778108f
Update Variants
schloerke Nov 14, 2024
47a7a48
Update ContentPackages and Packages
schloerke Nov 14, 2024
1206e0f
Update Sessions and Session
schloerke Nov 14, 2024
e754475
ActiveFinderSequence now inherits from ActiveSequence
schloerke Nov 14, 2024
f40cfe4
Minor updates and fix tests
schloerke Nov 14, 2024
474af27
Fix len bug
schloerke Nov 14, 2024
17c0270
Update Tasks and Task
schloerke Nov 14, 2024
197d1c0
Remove many debug prints
schloerke Nov 14, 2024
d5d6459
If it hasn't been initialized, use the object repr
schloerke Nov 14, 2024
d1c8b66
Update Permissions
schloerke Nov 14, 2024
074afad
Relax `json=` requirement to `Any`, matching `requests` package
schloerke Nov 14, 2024
faa2d8a
Update associations
schloerke Nov 14, 2024
fbfe8c8
Updated Integrations
schloerke Nov 14, 2024
bacbe85
Update Usage, Visits, and Metrics
schloerke Nov 14, 2024
b5fafdb
Relax return type; Remove ignore statements
schloerke Nov 14, 2024
5f5082d
Update User, Users, ContentItem, me, Content
schloerke Nov 14, 2024
7fd9282
Try using a tuple for base class for better python 3.8 support
schloerke Nov 14, 2024
0d3e9ae
Fix json errors in integration tests
schloerke Nov 14, 2024
890ffd1
Update Vanities
schloerke Nov 14, 2024
42580b9
Update Bundles
schloerke Nov 14, 2024
61eb20e
Update OAuth
schloerke Nov 14, 2024
04eb3fa
Update EnvVars
schloerke Nov 14, 2024
dd88ab2
Update Group/Groups
schloerke Nov 14, 2024
e948f55
Update env.py
schloerke Nov 14, 2024
a1b4440
Remove `resources` file
schloerke Nov 14, 2024
04a5c22
Merge branch 'main' into schloerke/324-merge-api-and-resource
schloerke Nov 14, 2024
c56d99a
Context now inherits from `dict`. Would be nice to remove it
schloerke Nov 14, 2024
d2811f6
Remove many commented code. Clarify some comments for now vs later
schloerke Nov 14, 2024
788938c
Update readme
schloerke Nov 14, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
187 changes: 163 additions & 24 deletions src/posit/connect/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@

> Note: this is design-by-wishful-thinking, not how things actually work today.
> To discuss or propose changes, open a PR suggesting new language.

### Connecting

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`).

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.

```
```python
from posit.connect import Client

con = Client()
Expand All @@ -18,31 +19,31 @@ con = Client()

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

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`).
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`).

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

* `.find()`: returns another iterable collection object.
* Calling `.find()` with no arguments retrieves all available entities
* If no entities match the query, `.find()` returns a length-0 collection.
* Iterating over a collection without having first called `find()` is equivalent to having queried for all.
* `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.
* Should `collection.find().find()` work? Probably.
* `.find()`: returns another iterable collection object.
* Calling `.find()` with no arguments retrieves all available entities
* If no entities match the query, `.find()` returns a length-0 collection.
* Iterating over a collection without having first called `find()` is equivalent to having queried for all.
* `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.
* Should `collection.find().find()` work? Probably.
* `.get(guid)` method that returns a single entity by id. If one is not found, it raises `NotFoundError`
* `.find_one()` is a convenience method that queries with `.find()` and returns a single entity
* If more than one entity match the query, `.find_one()` returns the first
* If no entities match, `.find_one()` returns `None`
* 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()`.
* `.to_pandas()` materializes the collection in a pandas `DataFrame`.
* pandas is not a required dependency of the SDK. `.to_pandas()` should try to import inside the method.
* If more than one entity match the query, `.find_one()` returns the first
* If no entities match, `.find_one()` returns `None`
* 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()`.
* `.to_pandas()` materializes the collection in a pandas `DataFrame`.
* pandas is not a required dependency of the SDK. `.to_pandas()` should try to import inside the method.

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

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

Entities have methods that are appropriate to them. Fields in the entity bodies can be accessed as properties.
Entities have methods that are appropriate to them. Fields in the entity bodies can be accessed as properties.

```
```python
for st in con.content.find(app_mode="streamlit"):
print(st.title)

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

### Mapping to HTTP request methods

Entities have an `.update()` method that maps to a `PATCH` request. `.delete()` is `DELETE`.
Entities have an `.update()` method that maps to a `PATCH` request. `.delete()` is `DELETE`.

```
```python
my_app.update(title="Quarterly Analysis of Team Velocity")
my_app.permissions.find_one(email="[email protected]").update(role="owner")
my_app.permissions.find_one(email="[email protected]").delete()
```

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.

```
```python
my_app.permissions.add(email="[email protected]", role="viewer")
```

### Field/attribute naming

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.
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.

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.
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.

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.
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.

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.

### Lower-level HTTP interface

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.
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.

### Constructing classes

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).

#### Classes

`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.

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.

```python
class ContentItem(..., ContentItemActiveDict):
...

@property
def repository(self) -> ContentItemRepository | None:
try:
return ContentItemRepository(self._ctx)
except ClientError:
return None

...
```

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.

Attempt to minimize the number of locations where the same intended `path` is defined. Preferably only at `._path` (so it works with `ApiCallMixin`).

```python
class Bundles(ApiCallMixin, ContextP[ContentItemContext]):
def __init__(
self,
ctx: ContentItemContext,
) -> None:
super().__init__()
self._ctx = ctx
self._path = f"v1/content/{ctx.content_guid}/bundles"
...
```

#### Context

* `Context` - A convenience class that holds information that can be passed down to child classes.
* Contains the request `.session` and `.url` information for easy API calls.
* 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).
* These classes help prevent an explosion of parameters being passed through the classes.
* `ContextP` - Protocol class that defines the attributes that a Context class should have.
* `ContextT` - Type variable that defines the type of the Context class.
* `ApiCallMixin` - Mixin class that provides helper methods for API calls and parsing the JSON repsonse. (e.g. `._get_api()`)
* It requires `._path: str` to be defined on the instance.

#### Ex: Content Item helper classes

These example classes show how the Entity and Context classes can be extended to provide helper classes for classes related to `ContentItem`.

* `ContentItemP` - Extends `ContextP` with context class set to `ContentItemContext`.
* `ContentItemContext` - Extends `Context` by including `content_path` and `content_guid` attributes.
* `ContentItemResourceDict` - Extends `ResourceDict` with context class set to `ContentItemContext`.
* `ContentItemActiveDict` - Extends `ActiveDict` with context class set to `ContentItemContext`.

#### Entity Classes

All entity classes are populated on initialization.

* `ReadOnlyDict` - A class that provides a read-only dictionary interface to the data.
* Immutable dictionary-like object that can be iterated over.
* `ResourceDict` - Extends `ReadOnlyDict`, but is aware of `._ctx: ContextT`.
* `ActiveDict` - Extends `ResourceDict`, but is aware of the API calls (`ApiCallMixin`) that can be made on the data.

Example: `Bundle` class's init method

```python
class BundleContext(ContentItemContext):
bundle_id: str

def __init__(self, ctx: ContentItemContext, /, *, bundle_id: str) -> None:
super().__init__(ctx, content_guid=ctx.content_guid)
self.bundle_id = bundle_id

class Bundle(ApiDictEndpoint[BundleContext]):
def __init__(self, ctx: ContentItemContext, /, **kwargs) -> None:
bundle_id = kwargs.get("id")
assert isinstance(bundle_id, str), f"Bundle 'id' must be a string. Got: {id}"
assert bundle_id, "Bundle 'id' must not be an empty string."

bundle_ctx = BundleContext(ctx, bundle_id=bundle_id)
path = f"v1/content/{ctx.content_guid}/bundles/{bundle_id}"
get_data = len(kwargs) == 1 # `id` is required
super().__init__(bundle_ctx, path, get_data, **kwargs)
...
```

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.

Example:

```python
class Association(ResourceDict):
class _Attrs(TypedDict, total=False):
app_guid: str
"""The unique identifier of the content item."""
oauth_integration_guid: str
"""The unique identifier of an existing OAuth integration."""
oauth_integration_name: str
"""A descriptive name that identifies the OAuth integration."""
oauth_integration_description: str
"""A brief text that describes the OAuth integration."""
oauth_integration_template: str
"""The template used to configure this OAuth integration."""
created_time: str
"""The timestamp (RFC3339) indicating when this association was created."""

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

#### Collection classes

* `ReadOnlySequence` - A class that provides a read-only list interface to the data.
* Immutable list-like object that can be iterated over.
* `ResourceSequence` - Extends `ReadOnlySequence`, but is aware of `._ctx: ContextT`.
* Wants data to immediately exist in the class.
* `ActiveSequence` - Extends `ResourceSequence`, but is aware of the API calls that can be made on the data. It requires `._path`
* Requires `._create_instance(path: str, **kwars: Any) -> ResourceDictT` method to be implemented.
* During initialization, if the data is not provided, it will be fetched from the API. (...unless `get_data=False` is passed as a parameter)
* `ActiveFinderSequence` - Extends `ActiveSequence` with `.find()` and `.find_by()` methods.

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).


When making a new class,
* Use a class to define the parameters and their types
* If attaching the type info class to the parent class, start with `_`. E.g.: `ContentItemRepository._Attrs`
* Document all attributes like normal
* 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.
* Inherit from `ApiDictEndpoint` or `ApiListEndpoint` as needed
* Init signature should be `def __init__(self, ctx: Context, path: str, /, **attrs: Jsonifiable) -> None:`
Loading