Skip to content

feat: Replace requests with httpx #44

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

Draft
wants to merge 4 commits into
base: multipart-upload
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ __version__ = "0.0.0"

[tool.poetry.dependencies]
python = "^3.8"
requests = ">=2,<3"
arrow = ">=0.4.4,<1"
pyparsing = ">=2.0,<3"
clique = "==1.6.1"
Expand Down
59 changes: 41 additions & 18 deletions source/ftrack_api/accessor/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,43 @@
import os
import hashlib
import base64
from typing import Optional, TYPE_CHECKING

import requests
import httpx

from .base import Accessor
from ..data import String
import ftrack_api.exception
from ftrack_api.uploader import Uploader
import ftrack_api.symbol
from .._http import ssl_context

if TYPE_CHECKING:
from ftrack_api.session import Session


class ServerFile(String):
"""Representation of a server file."""

def __init__(self, resource_identifier, session, mode="rb"):
def __init__(
self,
resource_identifier,
session: "Session",
mode="rb",
http: Optional[httpx.Client] = None,
):
"""Initialise file."""
self.mode = mode
self.resource_identifier = resource_identifier
self._session = session
self._has_read = False
self._http = (
http
if http is not None
else httpx.Client(
verify=ssl_context,
)
)

super(ServerFile, self).__init__()

Expand All @@ -46,25 +64,25 @@ def _read(self):
position = self.tell()
self.seek(0)

response = requests.get(
with self._http.stream(
"GET",
"{0}/component/get".format(self._session.server_url),
params={
"id": self.resource_identifier,
"username": self._session.api_user,
"apiKey": self._session.api_key,
},
stream=True,
)

try:
response.raise_for_status()
except requests.exceptions.HTTPError as error:
raise ftrack_api.exception.AccessorOperationFailedError(
"Failed to read data: {0}.".format(error)
)
follow_redirects=True,
) as response:
try:
response.raise_for_status()
except httpx.HTTPError as error:
raise ftrack_api.exception.AccessorOperationFailedError(
"Failed to read data: {0}.".format(error)
)

for block in response.iter_content(ftrack_api.symbol.CHUNK_SIZE):
self.wrapped_file.write(block)
for block in response.iter_bytes(ftrack_api.symbol.CHUNK_SIZE):
self.wrapped_file.write(block)

self.flush()
self.seek(position)
Expand Down Expand Up @@ -134,27 +152,32 @@ def _compute_checksum(self):
class _ServerAccessor(Accessor):
"""Provide server location access."""

def __init__(self, session, **kw):
def __init__(self, session: "Session", **kw):
"""Initialise location accessor."""
super(_ServerAccessor, self).__init__(**kw)

self.http = httpx.Client(
verify=ssl_context,
)
self._session = session

def open(self, resource_identifier, mode="rb"):
"""Return :py:class:`~ftrack_api.Data` for *resource_identifier*."""
return ServerFile(resource_identifier, session=self._session, mode=mode)
return ServerFile(
resource_identifier, session=self._session, mode=mode, http=self.http
)

def remove(self, resourceIdentifier):
"""Remove *resourceIdentifier*."""
response = requests.get(
response = self.http.get(
"{0}/component/remove".format(self._session.server_url),
params={
"id": resourceIdentifier,
"username": self._session.api_user,
"apiKey": self._session.api_key,
},
)
if response.status_code != 200:
if not response.is_success:
raise ftrack_api.exception.AccessorOperationFailedError(
"Failed to remove file."
)
Expand Down
4 changes: 2 additions & 2 deletions source/ftrack_api/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import os
from abc import ABCMeta, abstractmethod
import tempfile
import typing
from typing import BinaryIO


class Data(metaclass=ABCMeta):
Expand Down Expand Up @@ -51,7 +51,7 @@ def close(self):
class FileWrapper(Data):
"""Data wrapper for Python file objects."""

def __init__(self, wrapped_file: typing.IO):
def __init__(self, wrapped_file: BinaryIO):
"""Initialise access to *wrapped_file*."""
self.wrapped_file = wrapped_file
self._read_since_last_write = False
Expand Down
20 changes: 8 additions & 12 deletions source/ftrack_api/event/hub.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,14 @@
import socket
import ssl

import requests
import requests.exceptions
import httpx
import websocket

import ftrack_api.exception
import ftrack_api.event.base
import ftrack_api.event.subscriber
import ftrack_api.event.expression
from ftrack_api._http import ssl_context
from ftrack_api.logging import LazyLogMessage as L


Expand Down Expand Up @@ -859,29 +859,25 @@ def _get_socket_io_session(self):
}
if self._headers:
req_headers.update(self._headers)
response = requests.get(
response = httpx.get(
socket_io_url,
verify=ssl_context,
headers=req_headers,
cookies=self._cookies,
timeout=60, # 60 seconds timeout to recieve errors faster.
)
except requests.exceptions.Timeout as error:
except httpx.TimeoutException as error:
raise ftrack_api.exception.EventHubConnectionError(
"Timed out connecting to server: {0}.".format(error)
)
except requests.exceptions.SSLError as error:
raise ftrack_api.exception.EventHubConnectionError(
"Failed to negotiate SSL with server: {0}.".format(error)
)
except requests.exceptions.ConnectionError as error:
except httpx.ConnectError as error:
raise ftrack_api.exception.EventHubConnectionError(
"Failed to connect to server: {0}.".format(error)
)
else:
status = response.status_code
if status != 200:
if not response.is_success:
raise ftrack_api.exception.EventHubConnectionError(
"Received unexpected status code {0}.".format(status)
"Received unexpected status code {0}.".format(response.status_code)
)

# Parse result and return session information.
Expand Down
49 changes: 18 additions & 31 deletions source/ftrack_api/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,7 @@
import atexit
import warnings

import requests
import requests.auth
import requests.utils
import httpx
import arrow
import clique
import platformdirs
Expand All @@ -50,26 +48,26 @@
import ftrack_api.accessor.server
import ftrack_api._centralized_storage_scenario
import ftrack_api.logging
from ftrack_api._http import ssl_context
from ftrack_api.logging import LazyLogMessage as L

from weakref import WeakMethod


class SessionAuthentication(requests.auth.AuthBase):
class SessionAuthentication(httpx.Auth):
"""Attach ftrack session authentication information to requests."""

def __init__(self, api_key, api_user):
"""Initialise with *api_key* and *api_user*."""
self.api_key = api_key
self.api_user = api_user
super(SessionAuthentication, self).__init__()

def __call__(self, request):
def auth_flow(self, request: httpx.Request):
"""Modify *request* to have appropriate headers."""
request.headers.update(
{"ftrack-api-key": self.api_key, "ftrack-user": self.api_user}
)
return request
yield request


class Session(object):
Expand Down Expand Up @@ -246,30 +244,20 @@ def __init__(
self.merge_lock = threading.RLock()

self._managed_request = None
self._request = requests.Session()

if cookies:
if not isinstance(cookies, collections.abc.Mapping):
raise TypeError("The cookies argument is required to be a mapping.")
self._request.cookies.update(cookies)

if headers:
if not isinstance(headers, collections.abc.Mapping):
raise TypeError("The headers argument is required to be a mapping.")

headers = dict(headers)

else:
headers = {}

if not isinstance(strict_api, bool):
raise TypeError("The strict_api argument is required to be a boolean.")

headers.update({"ftrack-strict-api": "true" if strict_api is True else "false"})
headers = httpx.Headers(headers)
headers["ftrack-strict-api"] = "true" if strict_api is True else "false"

self._request.headers.update(headers)
self._request.auth = SessionAuthentication(self._api_key, self._api_user)
self.request_timeout = timeout
self._request = httpx.Client(
auth=SessionAuthentication(self._api_key, self._api_user),
verify=ssl_context,
cookies=cookies,
headers=headers,
timeout=timeout,
)

# Auto populating state is now thread-local
self._auto_populate = collections.defaultdict(lambda: auto_populate)
Expand All @@ -286,7 +274,7 @@ def __init__(
self._api_user,
self._api_key,
headers=headers,
cookies=requests.utils.dict_from_cookiejar(self._request.cookies),
cookies=self._request.cookies,
)

self._auto_connect_event_hub_thread = None
Expand Down Expand Up @@ -350,7 +338,7 @@ def __exit__(self, exception_type, exception_value, traceback):
self.close()

@property
def _request(self):
def _request(self) -> httpx.Client:
"""Return request session.

Raise :exc:`ftrack_api.exception.ConnectionClosedError` if session has
Expand Down Expand Up @@ -1631,8 +1619,7 @@ def call(self, data):
response = self._request.post(
url,
headers=headers,
data=data,
timeout=self.request_timeout,
content=data,
)
self.logger.debug(L("Call took: {0}", response.elapsed.total_seconds()))
self.logger.debug(L("Response: {0!r}", response.text))
Expand All @@ -1642,7 +1629,7 @@ def call(self, data):

# handle response exceptions and / or other http exceptions
# (strict api used => status code returned => raise_for_status() => HTTPError)
except requests.exceptions.HTTPError as exc:
except httpx.HTTPError as exc:
if "exception" in result:
error_message = "Server reported error: {0}({1})".format(
result["exception"], result["content"]
Expand Down
20 changes: 6 additions & 14 deletions test/unit/test_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,7 @@
import pytest
import mock
import arrow
import requests
import requests.utils
import httpx

import ftrack_api
import ftrack_api.cache
Expand Down Expand Up @@ -1113,14 +1112,14 @@ def test_load_schemas_bypassing_cache(mocker, session, temporary_valid_schema_ca
def test_get_tasks_widget_url(session):
"""Tasks widget URL returns valid HTTP status."""
url = session.get_widget_url("tasks")
response = requests.get(url)
response = httpx.get(url)
response.raise_for_status()


def test_get_info_widget_url(session, task):
"""Info widget URL for *task* returns valid HTTP status."""
url = session.get_widget_url("info", entity=task, theme="light")
response = requests.get(url)
response = httpx.get(url)
response.raise_for_status()


Expand Down Expand Up @@ -1466,25 +1465,18 @@ def test_strict_api_header():
"""Create ftrack session containing ftrack-strict-api = True header."""
new_session = ftrack_api.Session(strict_api=True)

assert (
"ftrack-strict-api" in new_session._request.headers.keys(),
new_session._request.headers["ftrack-strict-api"] == "true",
)
assert new_session._request.headers.get("ftrack-strict-api") == "true"


def test_custom_cookies_session():
"""Create ftrack session containing custom cookies."""
new_session = ftrack_api.Session(cookies={"abc": "def"})
cookies_dict = requests.utils.dict_from_cookiejar(new_session._request.cookies)

assert ("abc" in cookies_dict.keys(), cookies_dict["abc"] == "def")
assert new_session._request.cookies.get("abc") == "def"


def test_custom_headers_session():
"""Create ftrack session containing custom headers."""
new_session = ftrack_api.Session(headers={"abc": "def"})

assert (
"abc" in new_session._request.headers.keys(),
new_session._request.headers["abc"] == "def",
)
assert new_session._request.headers.get("abc") == "def"
Loading