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
13 changes: 12 additions & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v7
with:
fetch-depth: 2

- name: Setup Python
uses: actions/setup-python@v6
Expand All @@ -40,9 +42,18 @@ jobs:
run: |
uv run ruff format --check --diff .
uv run ruff check --diff .

- name: Run type checking
run: uv run ty check .

- name: Run Tests
run: uv run pytest

- name: Upload coverage to Codecov
if: matrix.python == '3.14'
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: peering-manager/pyixapi
files: coverage.xml
fail_ci_if_error: false
21 changes: 4 additions & 17 deletions pyixapi/core/query.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,8 @@ class Request(object):
Responsible for building the URL and making the HTTP(S) requests to the API.

:param base: (str) Base URL passed in api() instantiation.
:param filters: (dict, optional) contains key/value pairs that correlate to the
filters a given endpoint accepts.
In (e.g. /api/v1/devices?name='test') 'name': 'test' would be in the filters
dict.
:param filters: (dict, optional) key/value pairs matching the filters an
endpoint accepts, e.g. {"name": "test"} for /devices?name=test.
"""

def __init__(
Expand All @@ -84,17 +82,6 @@ def __init__(
self.user_agent = user_agent
self.proxies = proxies

def get_openapi(self) -> dict[str, Any]:
"""
Get the OpenAPI Spec.
"""
headers = {"Content-Type": "application/json;"}
req = self.http_session.get(cat(self.base, "docs/?format=openapi"), headers=headers)
if req.ok:
return req.json()
else:
raise RequestError(req)

def get_version(self) -> int:
"""
Get the API version of IX-API.
Expand Down Expand Up @@ -129,7 +116,7 @@ def _make_call(
add_params: dict[str, Any] | None = None,
data: dict[str, Any] | None = None,
) -> Any:
if verb in ("post", "put") or verb == "delete" and data:
if verb in ("post", "put") or (verb == "delete" and data):
headers: dict[str, str] = {"Content-Type": "application/json;"}
else:
headers = {"accept": "application/json;"}
Expand Down Expand Up @@ -184,7 +171,7 @@ def get(self, add_params: dict[str, Any] | None = None) -> Generator[dict[str, A
for i in req:
yield i
else:
self.count = len(req)
self.count = 1
yield req

def put(self, data: dict[str, Any]) -> dict[str, Any]:
Expand Down
38 changes: 16 additions & 22 deletions pyixapi/core/response.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,11 +90,11 @@ class Record(object):
Create Python objects from IX-API responses.

Nested dicts that represent other endpoints are also turned into
:py:class:`.Record` objects. All fields are then assigned to the object's
attributes. If a missing attribute is requested (e.g. requesting a field that's
only present on a full response on a :py:class:`.Record` made from a nested
response) then pyixapi will make a request for the full object and return the
requested value.
:py:class:`.Record` objects (when the corresponding model declares them as a
class attribute). All fields are then assigned to the object's attributes.

Only the fields present in the response are set as attributes; accessing a
field that was not returned raises :py:exc:`AttributeError`.

:examples:
Default representation of the object is usually its ID and/or name:
Expand Down Expand Up @@ -149,7 +149,6 @@ class Record(object):
url: str | None = None

def __init__(self, values: dict[str, Any], api: API, endpoint: Endpoint) -> None:
self._full_cache: list[Any] = []
self._init_cache: list[tuple[str, Any]] = []
self.api = api
self.default_ret: type[Record] = Record
Expand Down Expand Up @@ -179,12 +178,6 @@ def __str__(self) -> str:
def __repr__(self) -> str:
return str(dict(self))

def __getstate__(self) -> dict[str, Any]:
return self.__dict__

def __setstate__(self, d: dict[str, Any]) -> None:
self.__dict__.update(d)

def __key__(self) -> tuple[str, ...] | tuple[str]:
if hasattr(self, "id"):
return (self.endpoint.name, self.id)
Expand Down Expand Up @@ -212,8 +205,7 @@ def list_parser(key_name: str, list_item: Any) -> Any:
if isinstance(list_item, dict):
lookup = getattr(self.__class__, key_name, None)
if not isinstance(lookup, list):
# This is *list_parser*, so if the custom model field is not
# a list (or is not defined), just return the default model
# Field not declared as a list model: use the default Record.
return self.default_ret(list_item, self.api, self.endpoint)
else:
model = lookup[0]
Expand Down Expand Up @@ -259,16 +251,18 @@ def serialize(self, nested: bool = False, init: bool = False) -> Any:
return r

def _diff(self) -> set[str]:
def fmt_dict(k: str, v: Any) -> tuple[str, Any]:
def make_hashable(v: Any) -> Any:
# Hashable, structure-preserving form for set comparison. Lists become
# tuples, not a joined string (which would collide).
if isinstance(v, dict):
return k, Hashabledict(v)
return Hashabledict({k: make_hashable(val) for k, val in v.items()})
if isinstance(v, list):
return k, ",".join(map(str, v))
return k, v
return tuple(make_hashable(i) for i in v)
return v

current = Hashabledict({fmt_dict(k, v) for k, v in self.serialize().items()})
init = Hashabledict({fmt_dict(k, v) for k, v in self.serialize(init=True).items()})
return set([i[0] for i in set(current.items()) ^ set(init.items())])
current = {k: make_hashable(v) for k, v in self.serialize().items()}
init = {k: make_hashable(v) for k, v in self.serialize(init=True).items()}
return {key for key, _ in set(current.items()) ^ set(init.items())}

def updates(self) -> dict[str, Any]:
"""
Expand Down Expand Up @@ -343,4 +337,4 @@ def delete(self) -> bool:
user_agent=self.api.user_agent,
proxies=self.api.proxies,
)
return True if r.delete() else False
return r.delete()
3 changes: 0 additions & 3 deletions pyixapi/core/token.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,6 @@ def __init__(self, token: str, expires_at: datetime) -> None:
self.encoded: str = token # Cache signed token data
self.expires_at: datetime = expires_at

def __str__(self) -> str:
return self.encoded

def __repr__(self) -> str:
return f"<Token ttl={self.ttl}s>"

Expand Down
14 changes: 7 additions & 7 deletions pyixapi/core/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,14 @@ def cat(*args: Any, separator: str = "/", trailing: str = "") -> str:

If an item cannot be parsed as a string, an AttributeError will be raised.

>>> concatenate("a", "b", "c")
'a/b/c/'
>>> concatenate("a", "b", "/c/", separator="")
>>> cat("a", "/b/", "c/")
'a/b/c'
>>> concatenate("a", "/b/", 1)
'a/b/1/'
>>> concatenate("a", "b", "c", separator="_", trailing="_")
'a_b_c_'
>>> cat("a", 1, "b")
'a/1/b'
>>> cat("a", "b", "c", separator="_")
'a_b_c'
>>> cat("a", "b", "c", trailing="/")
'a/b/c/'
"""
s = separator.join([str(i).strip(separator) for i in args if str(i)])
if trailing:
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ invalid-return-type = "ignore"
unresolved-import = "ignore"

[tool.pytest.ini_options]
addopts = "--cov=pyixapi --cov-report=term-missing --cov-report=xml"
addopts = "--cov=pyixapi --cov-branch --cov-report=term-missing --cov-report=xml"

[tool.coverage.run]
source = ["pyixapi"]
Expand Down
4 changes: 0 additions & 4 deletions tests/fixtures/api/authenticate.json

This file was deleted.

4 changes: 0 additions & 4 deletions tests/fixtures/api/refresh_authentication.json

This file was deleted.

Loading
Loading