Skip to content
Merged
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
702 changes: 39 additions & 663 deletions README.md

Large diffs are not rendered by default.

29 changes: 28 additions & 1 deletion ofsc/async_client/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Async version of the OFSC client using httpx.AsyncClient."""

import logging
from typing import Optional

import httpx
Expand All @@ -23,6 +24,8 @@
from .oauth import AsyncOFSOauth2
from .statistics import AsyncOFSStatistics

logger = logging.getLogger(__name__)

__all__ = [
"AsyncOFSC",
"OFSAPIException",
Expand Down Expand Up @@ -79,7 +82,9 @@ def __init__(
access_token: Optional[str] = None,
enable_auto_raise: bool = True,
enable_auto_model: bool = True,
enable_logging: bool = False,
):
self._enable_logging = enable_logging
self._config = OFSConfig(
baseURL=baseUrl,
clientID=clientID,
Expand All @@ -100,7 +105,29 @@ def __init__(

async def __aenter__(self) -> "AsyncOFSC":
"""Enter async context manager - create shared httpx.AsyncClient."""
self._client = httpx.AsyncClient(http2=True)

async def log_request(request: httpx.Request) -> None:
logger.debug("Request: %s %s", request.method, request.url)

async def log_response(response: httpx.Response) -> None:
request = response.request
logger.debug("Response: %s %s %s", request.method, request.url, response.status_code)
if response.status_code >= 400:
logger.warning(
"HTTP error: %s %s %s",
request.method,
request.url,
response.status_code,
)

event_hooks: dict[str, list] = {}
if self._enable_logging:
event_hooks = {
"request": [log_request],
"response": [log_response],
}

self._client = httpx.AsyncClient(http2=True, event_hooks=event_hooks)
self._core = AsyncOFSCore(config=self._config, client=self._client)
self._metadata = AsyncOFSMetadata(config=self._config, client=self._client)
self._capacity = AsyncOFSCapacity(config=self._config, client=self._client)
Expand Down
2 changes: 2 additions & 0 deletions ofsc/async_client/core/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ def config(self) -> OFSConfig:

@property
def baseUrl(self) -> str:
if self._config.baseURL is None:
raise ValueError("Base URL is not configured")
return self._config.baseURL

@property
Expand Down
2 changes: 2 additions & 0 deletions ofsc/async_client/oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ def config(self) -> OFSConfig:

@property
def baseUrl(self) -> str:
if self._config.baseURL is None:
raise ValueError("Base URL is not configured")
return self._config.baseURL

@property
Expand Down
12 changes: 7 additions & 5 deletions ofsc/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@

from .exceptions import OFSAPIException

logger = logging.getLogger(__name__)

TEXT_RESPONSE = 1
FULL_RESPONSE = 2
OBJ_RESPONSE = 3
Expand All @@ -20,7 +22,7 @@ def wrap_return(*decorator_args, **decorator_kwargs):
def decorator(func):
@wraps(func)
def wrapper(*func_args, **func_kwargs):
logging.debug(f"{func_args=}, {func_kwargs=}, {decorator_args=}, {decorator_kwargs=}")
logger.debug(f"{func_args=}, {func_kwargs=}, {decorator_args=}, {decorator_kwargs=}")
config = func_args[0].config
# Pre:
response_type = func_kwargs.get("response_type", decorator_kwargs.get("response_type", OBJ_RESPONSE))
Expand All @@ -31,12 +33,12 @@ def wrapper(*func_args, **func_kwargs):

response = func(*func_args, **func_kwargs)
# post:
logging.debug(response)
logger.debug(response)

if response_type == FULL_RESPONSE:
return response
elif response_type == OBJ_RESPONSE:
logging.debug(f"{response_type=}, {config.auto_model=}, {model=} {func_args= } {func_kwargs=}")
logger.debug(f"{response_type=}, {config.auto_model=}, {model=} {func_args= } {func_kwargs=}")
if response.status_code in expected_codes:
match response.status_code:
case 204:
Expand All @@ -55,7 +57,7 @@ def wrapper(*func_args, **func_kwargs):
return response.json()
# Check if response.statyus code is between 400 and 499
if 400 <= response.status_code < 500:
logging.error(response.json())
logger.error(response.json())
raise OFSAPIException(**response.json())
elif 500 <= response.status_code < 600:
raise OFSAPIException(**response.json())
Expand All @@ -68,7 +70,7 @@ def wrapper(*func_args, **func_kwargs):
return response.json()
# Check if response.statyus code is between 400 and 499
if 400 <= response.status_code < 500:
logging.error(response.json())
logger.error(response.json())
raise OFSAPIException(**response.json())
elif 500 <= response.status_code < 600:
raise OFSAPIException(**response.json())
Expand Down
24 changes: 12 additions & 12 deletions ofsc/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@
ResourceWorkScheduleResponse,
)

logger = logging.getLogger(__name__)


class OFSCore(OFSApi):
# OFSC Function Library
Expand Down Expand Up @@ -117,14 +119,14 @@ def get_resource(
@wrap_return(response_type=OBJ_RESPONSE, expected=[200])
def create_resource(self, resourceId, data):
url = urljoin(self.baseUrl, f"/rest/ofscCore/v1/resources/{resourceId}")
logging.debug(f"OFSC.Create_Resource: {data} {type(data)}")
logger.debug(f"OFSC.Create_Resource: {data} {type(data)}")
response = requests.put(url, headers=self.headers, data=data)
return response

@wrap_return(response_type=OBJ_RESPONSE, expected=[200])
def create_resource_from_obj(self, resourceId, data):
url = urljoin(self.baseUrl, f"/rest/ofscCore/v1/resources/{resourceId}")
logging.debug(f"OFSC.Create_Resource: {data} {type(data)}")
logger.debug(f"OFSC.Create_Resource: {data} {type(data)}")
response = requests.put(url, headers=self.headers, data=json.dumps(data))
return response

Expand All @@ -134,7 +136,7 @@ def update_resource(self, resourceId, data: dict, identify_by_internal_id: bool
if identify_by_internal_id:
# add a query parameter to identify the resource by internal id
url += "?identifyResourceBy=resourceInternalId"
logging.debug(f"OFSC.Update_Resource: {data} {type(data)}")
logger.debug(f"OFSC.Update_Resource: {data} {type(data)}")
response = requests.patch(url, headers=self.headers, data=json.dumps(data))
return response

Expand Down Expand Up @@ -205,7 +207,7 @@ def get_resource_descendants(
params["fields"] = resourceFields
params["limit"] = limit
params["offset"] = offset
logging.debug(json.dumps(params, indent=2))
logger.debug(json.dumps(params, indent=2))

response = requests.get(url, params=params, headers=self.headers)
return response
Expand Down Expand Up @@ -275,7 +277,7 @@ def get_resources(
params["offset"] = offset
params["limit"] = limit

logging.debug(json.dumps(params, indent=2))
logger.debug(json.dumps(params, indent=2))
response = requests.get(url, params=params, headers=self.headers)
return response

Expand Down Expand Up @@ -421,7 +423,6 @@ def create_resource_location(self, resource_id, *, location: Location):
self.baseUrl,
f"/rest/ofscCore/v1/resources/{str(resource_id)}/locations",
)
print(location.model_dump(exclude="locationId", exclude_unset=True, exclude_none=True))
response = requests.post(
url,
headers=self.headers,
Expand Down Expand Up @@ -568,9 +569,8 @@ def get_all_activities(
"offset": offset,
"limit": limit,
}
logging.info(request_params)
logger.debug(request_params)
response = self.get_activities(response_type=FULL_RESPONSE, params=request_params)
print(response.json())
response_body = response.json()
if "items" in response_body.keys():
response_count = len(response_body["items"])
Expand All @@ -579,10 +579,10 @@ def get_all_activities(
response_count = 0
if "hasMore" in response_body.keys():
hasMore = response_body["hasMore"]
logging.info("{},{},{}".format(offset, response_count, response.elapsed))
logger.debug("{},{},{}".format(offset, response_count, response.elapsed))
else:
hasMore = False
logging.info("{},{},{}".format(offset, response_count, response.elapsed))
logger.debug("{},{},{}".format(offset, response_count, response.elapsed))
offset = offset + response_count
return OFSResponseList(items=items)

Expand All @@ -600,10 +600,10 @@ def get_all_properties(self, initial_offset=0, limit=100):
response_count = 0
if "hasMore" in response_body.keys():
hasMore = response_body["hasMore"]
logging.info("{},{},{}".format(offset, response_count, response.elapsed))
logger.debug("{},{},{}".format(offset, response_count, response.elapsed))
else:
hasMore = False
logging.info("{},{},{}".format(offset, response_count, response.elapsed))
logger.debug("{},{},{}".format(offset, response_count, response.elapsed))
offset = offset + response_count
return items

Expand Down
5 changes: 3 additions & 2 deletions ofsc/models/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@

from ..common import FULL_RESPONSE, wrap_return

logger = logging.getLogger(__name__)

# region Generic Models

T = TypeVar("T")
Expand Down Expand Up @@ -204,7 +206,7 @@ def baseUrl(self) -> str:
@wrap_return(response_type=FULL_RESPONSE, expected=[200])
def token(self, auth: OFSOAuthRequest = OFSOAuthRequest()) -> requests.Response:
headers = {}
logging.info(f"Getting token with {auth.grant_type}")
logger.info(f"Getting token with {auth.grant_type}")
if auth.grant_type == "client_credentials" or auth.grant_type == "urn:ietf:params:oauth:grant-type:jwt-bearer":
headers["Authorization"] = "Basic " + self._config.basicAuthString.decode("utf-8")
else:
Expand Down Expand Up @@ -232,7 +234,6 @@ def headers(self):
else:
self._token = self.token().json()["access_token"]
self._headers["Authorization"] = f"Bearer {self._token}"
print(f"Not implemented {self._token}")
return self._headers


Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "ofsc"
version = "2.24.0"
version = "2.24.1"
description = "Python wrapper for Oracle Field Service API"
authors = [{ name = "Borja Toron", email = "borja.toron@gmail.com" }]
requires-python = "~=3.11.0"
Expand Down
90 changes: 90 additions & 0 deletions tests/async/test_async_ofsc.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
"""Tests for AsyncOFSC class."""

import logging

import httpx
import pytest

from ofsc.async_client import AsyncOFSC
Expand Down Expand Up @@ -140,6 +143,93 @@ async def test_str_representation(self):
assert "mycompany" in str(client)


class TestAsyncOFSCLogging:
"""Test AsyncOFSC event hook logging."""

@pytest.mark.asyncio
async def test_logging_disabled_by_default(self):
"""Test that logging is disabled by default."""
client = AsyncOFSC(
clientID="test_client",
companyName="test_company",
secret="test_secret",
)
assert client._enable_logging is False

@pytest.mark.asyncio
async def test_logging_enabled_creates_event_hooks(self):
"""Test that enabling logging configures httpx event hooks."""
async with AsyncOFSC(
clientID="test_client",
companyName="test_company",
secret="test_secret",
enable_logging=True,
) as client:
assert len(client._client.event_hooks["request"]) == 1
assert len(client._client.event_hooks["response"]) == 1

@pytest.mark.asyncio
async def test_logging_disabled_no_event_hooks(self):
"""Test that disabling logging results in no custom event hooks."""
async with AsyncOFSC(
clientID="test_client",
companyName="test_company",
secret="test_secret",
enable_logging=False,
) as client:
assert len(client._client.event_hooks["request"]) == 0
assert len(client._client.event_hooks["response"]) == 0

@pytest.mark.asyncio
async def test_request_hook_logs_at_debug(self, caplog):
"""Test that request hook logs at DEBUG level."""
async with AsyncOFSC(
clientID="test_client",
companyName="test_company",
secret="test_secret",
enable_logging=True,
) as client:
hook = client._client.event_hooks["request"][0]
request = client._client.build_request("GET", "https://example.com/test")
with caplog.at_level(logging.DEBUG, logger="ofsc.async_client"):
await hook(request)
assert "Request: GET https://example.com/test" in caplog.text

@pytest.mark.asyncio
async def test_response_hook_logs_at_debug(self, caplog):
"""Test that response hook logs at DEBUG level for successful responses."""
async with AsyncOFSC(
clientID="test_client",
companyName="test_company",
secret="test_secret",
enable_logging=True,
) as client:
hook = client._client.event_hooks["response"][0]
request = client._client.build_request("GET", "https://example.com/test")
response = httpx.Response(200, request=request)
with caplog.at_level(logging.DEBUG, logger="ofsc.async_client"):
await hook(response)
assert "Response: GET https://example.com/test 200" in caplog.text

@pytest.mark.asyncio
async def test_response_hook_warns_on_http_error(self, caplog):
"""Test that response hook logs a WARNING for 4xx/5xx status codes."""
async with AsyncOFSC(
clientID="test_client",
companyName="test_company",
secret="test_secret",
enable_logging=True,
) as client:
hook = client._client.event_hooks["response"][0]
request = client._client.build_request("GET", "https://example.com/test")
response = httpx.Response(404, request=request)
with caplog.at_level(logging.DEBUG, logger="ofsc.async_client"):
await hook(response)
assert "HTTP error: GET https://example.com/test 404" in caplog.text
warning_records = [r for r in caplog.records if r.levelno == logging.WARNING]
assert len(warning_records) == 1


class TestAsyncOFSMetadataStubs:
"""Test that AsyncOFSMetadata stub methods raise NotImplementedError."""

Expand Down
2 changes: 1 addition & 1 deletion uv.lock

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