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

feat: adds abstraction for resources and subsequent implementation of users. #20

Merged
merged 3 commits into from
Feb 15, 2024

Conversation

tdstein
Copy link
Collaborator

@tdstein tdstein commented Feb 15, 2024

See comments below.

I will come back around and implement the full unit test suite for resources.py and users.py.

Copy link

github-actions bot commented Feb 15, 2024

☂️ Python Coverage

current status: ✅

Overall Coverage

Lines Covered Coverage Threshold Status
336 287 85% 0% 🟢

New Files

File Coverage Status
src/posit/connect/config.py 100% 🟢
src/posit/connect/config_test.py 100% 🟢
src/posit/connect/endpoints.py 100% 🟢
src/posit/connect/endpoints_test.py 100% 🟢
src/posit/connect/resources.py 51% 🟢
src/posit/connect/resources_test.py 96% 🟢
TOTAL 91% 🟢

Modified Files

File Coverage Status
src/posit/connect/init.py 100% 🟢
src/posit/connect/auth.py 100% 🟢
src/posit/connect/auth_test.py 100% 🟢
src/posit/connect/client.py 100% 🟢
src/posit/connect/client_test.py 100% 🟢
src/posit/connect/users.py 62% 🟢
src/posit/connect/users_test.py 100% 🟢
TOTAL 95% 🟢

updated for commit: 6a928cb by action🐍

Comment on lines +1 to +10
from posit.connect.client import create_client

client = Client()
res = client.users.get_current_user()
print(res.json())
with create_client() as client:
print(client.users.get("f55ca95d-ce52-43ed-b31b-48dc4a07fe13"))

users = client.users
users = users.find(lambda user: user["first_name"].startswith("T"))
users = users.find(lambda user: user["last_name"].startswith("S"))
user = users.find_one(lambda user: user["user_role"] == "administrator")
print(user)
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here is some example usage.

from typing import Generic, Iterator, Optional, TypeVar, List, TypedDict, Tuple


class Resource(TypedDict):
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@nealrichardson - TypedDict seems to be the best of both worlds. We get dict behavior and typed autocomplete, linting, etc...

Comment on lines +34 to +35
def find_one(self, filter: Callable[[User], bool] = lambda _: True) -> User | None:
return next((user for user in self if filter(user)), None)
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm currently in favor of using functions as arguments for filtering. This allows for more expressive filtering.

This also takes advantage of the lazy-iterable implementation. Once a match is found here, the iterable stops, meaning subsequent fetches to the server are skipped.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is nice, and I like the expressiveness, but it's not compatible with server-side querying/searching, and it also doesn't help the user discover the valid parameter names. It's also annoying to have to write a lambda when I want an exact match (e.g. user.email == me).

So my initial take is +1 for having a filter argument like this, but it shouldn't be the only way.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, yes, thanks for the reminder regarding server-side params.

Comment on lines +55 to +59
if (index % self.page_size) != 0:
#
raise ValueError(
f"index ({index}) must be a multiple of page size ({self.page_size})"
)
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional logic can be implemented to handle partial fetches, but it's unnecessary since this should only be called at the start of a new page.

Comment on lines +37 to +41
def get(self, id: str) -> User:
user = next((user for user in self if user["guid"] == id), None)
if user is None:
raise RuntimeError(f"failed to get user with id '{id}'")
return user
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Notice that there are implementations of get in Users and LazyUsers.

The following implementations therefore have slightly different behavior.

LazyUsers(...).get(...) will call the server.

Users(...).get(...) will NOT call the server.

LazyUsers(...).find(...).get(...) will NOT call the server since .find returns an instance of Users, not LazyUsers.

Generally, an in-memory list iteration will execute faster than a client/server round-trip.

There are other implications that need to be addressed. E.g., what happens when we implement .create?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How does one get LazyUsers or just Users?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current entry point is client.users:

self.users: Users = LazyUsers(config=config, session=session)

with create_client(...) as client:
    users = client.users.find(...).get(...)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Naming is tough, but if I'm inspecting types I get back from the client it might be nice to see something more like Users and CachedUsers.

Comment on lines +44 to +48
- if: always()
uses: orgoro/[email protected]
with:
coverageFile: coverage.xml
token: ${{ secrets.GITHUB_TOKEN }}
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cov will fails < 100%, but show the report anyways in the pull request.

@tdstein tdstein marked this pull request as ready for review February 15, 2024 16:14
@tdstein
Copy link
Collaborator Author

tdstein commented Feb 15, 2024

I'm going to merge this to get things on main. Feel free to review it anyway, and I'll follow up.

@tdstein tdstein merged commit 5d95633 into main Feb 15, 2024
6 of 7 checks passed
from typing import Optional


def _get_api_key() -> str:

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You might consider using NewType to make distinct string types for things that have special meaning (API keys, server URLs, endpoints, ...).

endpoint = os.path.join(self._endpoint, "__api__/v1/user")
return self._session.get(endpoint)
def get(self, id: str) -> User:
endpoint = os.path.join(self.config.endpoint, "v1/users", id)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you'll need urllib.parse.urljoin or posixpath.join, because os.path.join will use os.sep which will be a backslash on Windows.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks. I'll get a safer implementation together! This was quick and dirty :)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

import os
import requests

_MAX_PAGE_SIZE = 500
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it looks like we aren't using max_page_size as an enforcement tool, but rather as a default value in some places. Wondering if you intend to actually enforce a max page size anywhere - if not, maybe this variable should have a different name?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, yes. Good point. _MAX_PAGE_SIZE means "this is the max page size I can use," but you're right. _PAGE_SIZE plus some ValueError validation would be better.

@nealrichardson nealrichardson deleted the lazy-resources branch February 16, 2024 19:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants