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

Implement asyncio support, plus session optimizations #87

Open
wants to merge 15 commits into
base: master
Choose a base branch
from

Conversation

seandstewart
Copy link

@seandstewart seandstewart commented Feb 22, 2024

This change adds support for asyncio. In order to do so, I made the following changes:

  1. Isolated the use of the libraries behind a "Transport" abstraction.
  2. Integrated errors and error handling within the "Transport" implementations.
  3. Split the client implementation into BaseClient, Client, and AsyncClient.
  4. Transports are designed to re-use a Session (connection pool), which each library provides. This is much more efficient than the top-level functions in requests or re-creating a session for every call.
  5. Transports automatically handle deserialization from JSON and have default error handling for non-HTTP errors.
  6. Adds tests for the new AsyncClient
  7. Migrates the packaging to a modern toolchain for future-proofing (pyproject.toml, Poetry)

resolves #86

@seandstewart seandstewart marked this pull request as draft February 22, 2024 22:41
@seandstewart seandstewart changed the title DRAFT: Implement asyncio support, plus session optimizations Implement asyncio support, plus session optimizations Feb 22, 2024
@seandstewart seandstewart marked this pull request as ready for review February 28, 2024 19:27
@seandstewart
Copy link
Author

@rubydog I'd appreciate a review when you have time, this is a major blocker for my company's onboarding. Here is the test output below:

❯ make test-all
tox
.pkg: _optional_hooks> python /Users/seanstewart/PycharmProjects/contentful.py/venv/lib/python3.9/site-packages/pyproject_api/_backend.py True setuptools.build_meta __legacy__
.pkg: get_requires_for_build_sdist> python /Users/seanstewart/PycharmProjects/contentful.py/venv/lib/python3.9/site-packages/pyproject_api/_backend.py True setuptools.build_meta __legacy__
.pkg: get_requires_for_build_wheel> python /Users/seanstewart/PycharmProjects/contentful.py/venv/lib/python3.9/site-packages/pyproject_api/_backend.py True setuptools.build_meta __legacy__
.pkg: prepare_metadata_for_build_wheel> python /Users/seanstewart/PycharmProjects/contentful.py/venv/lib/python3.9/site-packages/pyproject_api/_backend.py True setuptools.build_meta __legacy__
.pkg: build_sdist> python /Users/seanstewart/PycharmProjects/contentful.py/venv/lib/python3.9/site-packages/pyproject_api/_backend.py True setuptools.build_meta __legacy__
py38-flakes: install_package> python -I -m pip install --force-reinstall --no-deps /Users/seanstewart/PycharmProjects/contentful.py/.tox/.tmp/package/33/contentful-2.1.1.tar.gz
py38-flakes: commands[0]> python -m unittest discover tests
.................................................................................................................................................................
----------------------------------------------------------------------
Ran 161 tests in 0.819s

OK
py38-flakes: OK ✔ in 2.25 seconds
py39-flakes: install_package> python -I -m pip install --force-reinstall --no-deps /Users/seanstewart/PycharmProjects/contentful.py/.tox/.tmp/package/34/contentful-2.1.1.tar.gz
py39-flakes: commands[0]> python -m unittest discover tests
.................................................................................................................................................................
----------------------------------------------------------------------
Ran 161 tests in 0.415s

OK
py39-flakes: OK ✔ in 1.98 seconds
py310-flakes: install_package> python -I -m pip install --force-reinstall --no-deps /Users/seanstewart/PycharmProjects/contentful.py/.tox/.tmp/package/35/contentful-2.1.1.tar.gz
py310-flakes: commands[0]> python -m unittest discover tests
.................................................................................................................................................................
----------------------------------------------------------------------
Ran 161 tests in 0.431s

OK
py310-flakes: OK ✔ in 1.5 seconds
py311-flakes: install_package> python -I -m pip install --force-reinstall --no-deps /Users/seanstewart/PycharmProjects/contentful.py/.tox/.tmp/package/36/contentful-2.1.1.tar.gz
py311-flakes: commands[0]> python -m unittest discover tests
.................................................................................................................................................................
----------------------------------------------------------------------
Ran 161 tests in 0.355s

OK
py311-flakes: OK ✔ in 1.46 seconds
py312-flakes: install_package> python -I -m pip install --force-reinstall --no-deps /Users/seanstewart/PycharmProjects/contentful.py/.tox/.tmp/package/37/contentful-2.1.1.tar.gz
py312-flakes: commands[0]> python -m unittest discover tests
.................................................................................................................................................................
----------------------------------------------------------------------
Ran 161 tests in 0.304s

OK
py312-flakes: OK ✔ in 2.54 seconds
pypy3-flakes: install_package> python -I -m pip install --force-reinstall --no-deps /Users/seanstewart/PycharmProjects/contentful.py/.tox/.tmp/package/38/contentful-2.1.1.tar.gz
pypy3-flakes: commands[0]> python -m unittest discover tests
.............Executing <Task pending name='Task-25' coro=<IsolatedAsyncioTestCase._asyncioLoopRunner() running at /Users/seanstewart/.pyenv/versions/pypy3.10-7.3.13/lib/pypy3.10/unittest/async_case.py:101> created at /Users/seanstewart/.pyenv/versions/pypy3.10-7.3.13/lib/pypy3.10/unittest/async_case.py:117> took 0.129 seconds
....................................................................................................................................................
----------------------------------------------------------------------
Ran 161 tests in 1.303s

OK
.pkg: _exit> python /Users/seanstewart/PycharmProjects/contentful.py/venv/lib/python3.9/site-packages/pyproject_api/_backend.py True setuptools.build_meta __legacy__
  py38-flakes: OK (2.25=setup[1.19]+cmd[1.05] seconds)
  py39-flakes: OK (1.98=setup[1.33]+cmd[0.65] seconds)
  py310-flakes: OK (1.50=setup[0.84]+cmd[0.67] seconds)
  py311-flakes: OK (1.46=setup[0.87]+cmd[0.59] seconds)
  py312-flakes: OK (2.54=setup[2.01]+cmd[0.53] seconds)
  pypy3-flakes: OK (3.26=setup[1.55]+cmd[1.71] seconds)
  congratulations :) (13.11 seconds)

@rubydog
Copy link
Collaborator

rubydog commented Mar 6, 2024

Hi @seandstewart, thanks for the PR.

I will take a look at it and try to get back to you by next week.

Cheers

Comment on lines +152 to +156
def initialize(self):
raise NotImplementedError()

def teardown(self):
raise NotImplementedError()
Copy link
Author

Choose a reason for hiding this comment

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

Introduced an initializer method and a clean up method - these should be called by the developer at app startup and app shutdown.

Comment on lines +158 to +171
def _get(self, url: str, query: QueryT | None = None):
"""
Wrapper for the HTTP Request,
Rate Limit Backoff is handled here,
Responses are Processed with ResourceBuilder.
"""
raise NotImplementedError()

def _cache_content_types(self):
"""
Updates the Content Type Cache.
"""

raise NotImplementedError()
Copy link
Author

Choose a reason for hiding this comment

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

Implementation details handled by subclasses.

Comment on lines +173 to +192
@property
def client_info(self) -> ClientInfo:
if self._client_info is None:
self._client_info = self._get_client_info()

return self._client_info

@property
def headers(self) -> dict[str, str]:
if self._headers is None:
self._headers = self._request_headers()

return self._headers

@property
def proxy_info(self) -> abstract.ProxyInfo:
if self._proxy_info is None:
self._proxy_info = self._proxy_parameters()

return self._proxy_info
Copy link
Author

Choose a reason for hiding this comment

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

These were re-created with every request to Contentful. We're now only generating this information once, which is much more efficient.

Comment on lines +194 to +221
@property
def transport(self) -> abstract.AbstractTransport:
if self._transport is None:
self._transport = self._get_transport()

return self._transport

def qualified_url(self) -> str:
scheme = "https://" if self.https else "http://"
hostname = self.api_url
if hostname.startswith("http"):
scheme = ""

path = f"/spaces/{self.space_id}/environments/{self.environment}/"
url = f"{scheme}{hostname}{path}"
return url

def _get_transport(self) -> abstract.AbstractTransport:
base_url = self.qualified_url()
transport = self.transport_cls(
base_url=base_url,
timeout_s=self.timeout_s,
proxy_info=self.proxy_info,
default_headers=self.headers,
max_retries=self.max_rate_limit_retries,
max_retry_wait_seconds=self.max_rate_limit_wait,
)
return transport
Copy link
Author

Choose a reason for hiding this comment

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

We initialize the transport lazily and provide the fully-qualified URL to the targeted Contentful space/env as a base_url to the Transport.

Comment on lines +313 to +319
def _format_params(self, query: QueryT | None) -> dict[str, str]:
query = query or {}
params = queries.normalize(**query)
if not self.authorization_as_header:
params["access_token"] = self.access_token

return params
Copy link
Author

Choose a reason for hiding this comment

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

Isolated query-string formatting into a more efficient, generic normalizer.

Comment on lines +16 to +35
__all__ = (
"Client",
"AsyncClient",
"Entry",
"Asset",
"Space",
"Locale",
"Link",
"ContentType",
"DeletedAsset",
"DeletedEntry",
"ContentTypeCache",
"ContentTypeField",
)

_metadata = metadata.metadata(__package__)

__version__ = _metadata.get("version")
__author__ = _metadata.get("author")
__email__ = _metadata.get("author-email")
Copy link
Author

Choose a reason for hiding this comment

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

Defining an __all__ and using importlib.metadata to get package metadata.

)
logger.debug(
f"{prefix}{retry_message}",
extra={"tries": tries, "reset_time": reset_time},
Copy link
Author

Choose a reason for hiding this comment

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

Passing in extra= will work well with structured logging for app developers.

Comment on lines +36 to +41
def update_cache(cls, *, space_id: str, content_types: list[ContentType]):
"""
Updates the Cache with all Content Types from the Space.
"""

cls.__CACHE__[client.space_id] = client.content_types()
cls.__CACHE__[space_id] = content_types
Copy link
Author

Choose a reason for hiding this comment

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

Breaking change: Can't implicitly call the client - interface now requires that you call the client and pass in the result.

Copy link
Author

Choose a reason for hiding this comment

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

Copied from test_client and updated for async.

Comment on lines +129 to +133
error = errors.get_error_for_status_code(
response.status_code,
content=response.content,
headers=response.headers,
)
Copy link
Author

Choose a reason for hiding this comment

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

Only major update was to migrate to the new function.

@seandstewart
Copy link
Author

seandstewart commented Mar 6, 2024

Hi @seandstewart, thanks for the PR.

I will take a look at it and try to get back to you by next week.

Cheers

@rubydog Sounds good. I've gone through and added a self-review!

@pdelagrave
Copy link

Hello, any status update for this PR?
We're eager to try it out :)
Thanks

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.

Python EOL Version Support
3 participants