pyixapi 0.2.8__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyixapi
3
- Version: 0.2.8
3
+ Version: 0.3.0
4
4
  Summary: Python API client library for IX-API
5
5
  Author: Guillaume Mazoyer
6
6
  Author-email: Guillaume Mazoyer <oss@mazoyer.eu>
@@ -12,7 +12,7 @@ Classifier: Programming Language :: Python :: 3.11
12
12
  Classifier: Programming Language :: Python :: 3.12
13
13
  Classifier: Programming Language :: Python :: 3.13
14
14
  Classifier: Programming Language :: Python :: 3.14
15
- Requires-Dist: pyjwt>=2.4.0,<2.13
15
+ Requires-Dist: pyjwt>=2.4.0,<2.14
16
16
  Requires-Dist: requests>=2.32.4,<3.0
17
17
  Requires-Python: >=3.10
18
18
  Description-Content-Type: text/markdown
@@ -12,6 +12,7 @@ from pyixapi.models import (
12
12
  IP,
13
13
  MAC,
14
14
  Account,
15
+ AvailabilityZone,
15
16
  Connection,
16
17
  Contact,
17
18
  Demarc,
@@ -30,9 +31,10 @@ from pyixapi.models import (
30
31
  ProductOffering,
31
32
  Role,
32
33
  RoleAssignment,
34
+ RoutingFunction,
33
35
  )
34
36
 
35
- __version__ = "0.2.8"
37
+ __version__ = "0.3.0"
36
38
 
37
39
 
38
40
  class API(object):
@@ -61,11 +63,11 @@ class API(object):
61
63
  self.http_session = requests.Session()
62
64
  self.user_agent = user_agent
63
65
  self.proxies = proxies
66
+ self._version: int | None = None
64
67
 
65
68
  self.auth = Endpoint(self, "auth")
66
69
  self.connections = Endpoint(self, "connections", model=Connection)
67
70
  self.contacts = Endpoint(self, "contacts", model=Contact)
68
- self.demarcs = Endpoint(self, "demarcs", model=Demarc)
69
71
  self.devices = Endpoint(self, "devices", model=Device)
70
72
  self.facilities = Endpoint(self, "facilities", model=Facility)
71
73
  self.ips = Endpoint(self, "ips", model=IP)
@@ -76,6 +78,7 @@ class API(object):
76
78
  self.network_services = Endpoint(self, "network-services", model=NetworkService)
77
79
  self.pops = Endpoint(self, "pops", model=PoP)
78
80
  # Version 2+
81
+ self.availability_zones = Endpoint(self, "availability-zones", model=AvailabilityZone)
79
82
  self.member_joining_rules = Endpoint(self, "member-joining-rules", model=MemberJoiningRule)
80
83
  self.metro_areas = Endpoint(self, "metro-areas", model=MetroArea)
81
84
  self.metro_area_networks = Endpoint(self, "metro-area-networks", model=MetroAreaNetwork)
@@ -83,12 +86,19 @@ class API(object):
83
86
  self.port_reservations = Endpoint(self, "port-reservations", model=PortReservation)
84
87
  self.roles = Endpoint(self, "roles", model=Role)
85
88
  self.role_assignments = Endpoint(self, "role-assignments", model=RoleAssignment)
89
+ self.routing_functions = Endpoint(self, "routing-functions", model=RoutingFunction)
86
90
 
87
91
  @property
88
92
  def version(self) -> int:
89
93
  """
90
94
  Get the API version of IX-API.
95
+
96
+ The version is resolved once on first access and cached for the lifetime
97
+ of the API instance, as it does not change between requests.
91
98
  """
99
+ if self._version is not None:
100
+ return self._version
101
+
92
102
  version = Request(
93
103
  base=self.url,
94
104
  token=self.access_token,
@@ -104,12 +114,19 @@ class API(object):
104
114
  stacklevel=2,
105
115
  )
106
116
 
117
+ self._version = version
107
118
  return version
108
119
 
109
120
  @property
110
121
  def accounts(self) -> Endpoint:
111
122
  return Endpoint(self, "customers" if self.version == 1 else "accounts", model=Account)
112
123
 
124
+ @property
125
+ def demarcs(self) -> Endpoint:
126
+ if self.version != 1:
127
+ raise AttributeError("demarcs endpoint is only available in IX-API v1")
128
+ return Endpoint(self, "demarcs", model=Demarc)
129
+
113
130
  @property
114
131
  def product_offerings(self) -> Endpoint:
115
132
  return Endpoint(
@@ -118,6 +135,21 @@ class API(object):
118
135
  model=ProductOffering,
119
136
  )
120
137
 
138
+ def account(self) -> Account:
139
+ """
140
+ Get the authenticated user's own account.
141
+
142
+ Available in IX-API 2 or newer.
143
+ """
144
+ r = Request(
145
+ base=cat(self.url, "account"),
146
+ token=self.access_token,
147
+ http_session=self.http_session,
148
+ user_agent=self.user_agent,
149
+ proxies=self.proxies,
150
+ )
151
+ return Account(r._make_call(), self, self.accounts)
152
+
121
153
  def authenticate(self) -> Record | None:
122
154
  """
123
155
  Authenticate and generate a pair of tokens.
@@ -168,6 +200,20 @@ class API(object):
168
200
 
169
201
  return Record(r, self, self.auth)
170
202
 
203
+ def extensions(self) -> list[dict[str, Any]]:
204
+ """
205
+ Get the list of extensions supported by the IX-API implementation.
206
+
207
+ Available in IX-API 2 or newer.
208
+ """
209
+ return Request(
210
+ base=cat(self.url, "extensions"),
211
+ token=self.access_token,
212
+ http_session=self.http_session,
213
+ user_agent=self.user_agent,
214
+ proxies=self.proxies,
215
+ )._make_call()
216
+
171
217
  def health(self) -> dict[str, Any]:
172
218
  """
173
219
  Get the health information from IX-API.
@@ -184,3 +230,17 @@ class API(object):
184
230
  user_agent=self.user_agent,
185
231
  proxies=self.proxies,
186
232
  ).get_health()
233
+
234
+ def implementation(self) -> dict[str, Any]:
235
+ """
236
+ Get implementation details of the IX-API server.
237
+
238
+ Available in IX-API 2 or newer.
239
+ """
240
+ return Request(
241
+ base=cat(self.url, "implementation"),
242
+ token=self.access_token,
243
+ http_session=self.http_session,
244
+ user_agent=self.user_agent,
245
+ proxies=self.proxies,
246
+ )._make_call()
@@ -59,10 +59,8 @@ class Request(object):
59
59
  Responsible for building the URL and making the HTTP(S) requests to the API.
60
60
 
61
61
  :param base: (str) Base URL passed in api() instantiation.
62
- :param filters: (dict, optional) contains key/value pairs that correlate to the
63
- filters a given endpoint accepts.
64
- In (e.g. /api/v1/devices?name='test') 'name': 'test' would be in the filters
65
- dict.
62
+ :param filters: (dict, optional) key/value pairs matching the filters an
63
+ endpoint accepts, e.g. {"name": "test"} for /devices?name=test.
66
64
  """
67
65
 
68
66
  def __init__(
@@ -84,17 +82,6 @@ class Request(object):
84
82
  self.user_agent = user_agent
85
83
  self.proxies = proxies
86
84
 
87
- def get_openapi(self) -> dict[str, Any]:
88
- """
89
- Get the OpenAPI Spec.
90
- """
91
- headers = {"Content-Type": "application/json;"}
92
- req = self.http_session.get(cat(self.base, "docs/?format=openapi"), headers=headers)
93
- if req.ok:
94
- return req.json()
95
- else:
96
- raise RequestError(req)
97
-
98
85
  def get_version(self) -> int:
99
86
  """
100
87
  Get the API version of IX-API.
@@ -129,7 +116,7 @@ class Request(object):
129
116
  add_params: dict[str, Any] | None = None,
130
117
  data: dict[str, Any] | None = None,
131
118
  ) -> Any:
132
- if verb in ("post", "put") or verb == "delete" and data:
119
+ if verb in ("post", "put") or (verb == "delete" and data):
133
120
  headers: dict[str, str] = {"Content-Type": "application/json;"}
134
121
  else:
135
122
  headers = {"accept": "application/json;"}
@@ -184,7 +171,7 @@ class Request(object):
184
171
  for i in req:
185
172
  yield i
186
173
  else:
187
- self.count = len(req)
174
+ self.count = 1
188
175
  yield req
189
176
 
190
177
  def put(self, data: dict[str, Any]) -> dict[str, Any]:
@@ -3,7 +3,7 @@ from __future__ import annotations
3
3
  from typing import TYPE_CHECKING, Any, Iterator
4
4
 
5
5
  from pyixapi.core.query import Request
6
- from pyixapi.core.util import Hashabledict
6
+ from pyixapi.core.util import Hashabledict, cat
7
7
 
8
8
  if TYPE_CHECKING:
9
9
  from pyixapi.core.api import API
@@ -90,11 +90,11 @@ class Record(object):
90
90
  Create Python objects from IX-API responses.
91
91
 
92
92
  Nested dicts that represent other endpoints are also turned into
93
- :py:class:`.Record` objects. All fields are then assigned to the object's
94
- attributes. If a missing attribute is requested (e.g. requesting a field that's
95
- only present on a full response on a :py:class:`.Record` made from a nested
96
- response) then pyixapi will make a request for the full object and return the
97
- requested value.
93
+ :py:class:`.Record` objects (when the corresponding model declares them as a
94
+ class attribute). All fields are then assigned to the object's attributes.
95
+
96
+ Only the fields present in the response are set as attributes; accessing a
97
+ field that was not returned raises :py:exc:`AttributeError`.
98
98
 
99
99
  :examples:
100
100
  Default representation of the object is usually its ID and/or name:
@@ -149,7 +149,6 @@ class Record(object):
149
149
  url: str | None = None
150
150
 
151
151
  def __init__(self, values: dict[str, Any], api: API, endpoint: Endpoint) -> None:
152
- self._full_cache: list[Any] = []
153
152
  self._init_cache: list[tuple[str, Any]] = []
154
153
  self.api = api
155
154
  self.default_ret: type[Record] = Record
@@ -179,12 +178,6 @@ class Record(object):
179
178
  def __repr__(self) -> str:
180
179
  return str(dict(self))
181
180
 
182
- def __getstate__(self) -> dict[str, Any]:
183
- return self.__dict__
184
-
185
- def __setstate__(self, d: dict[str, Any]) -> None:
186
- self.__dict__.update(d)
187
-
188
181
  def __key__(self) -> tuple[str, ...] | tuple[str]:
189
182
  if hasattr(self, "id"):
190
183
  return (self.endpoint.name, self.id)
@@ -212,8 +205,7 @@ class Record(object):
212
205
  if isinstance(list_item, dict):
213
206
  lookup = getattr(self.__class__, key_name, None)
214
207
  if not isinstance(lookup, list):
215
- # This is *list_parser*, so if the custom model field is not
216
- # a list (or is not defined), just return the default model
208
+ # Field not declared as a list model: use the default Record.
217
209
  return self.default_ret(list_item, self.api, self.endpoint)
218
210
  else:
219
211
  model = lookup[0]
@@ -259,16 +251,18 @@ class Record(object):
259
251
  return r
260
252
 
261
253
  def _diff(self) -> set[str]:
262
- def fmt_dict(k: str, v: Any) -> tuple[str, Any]:
254
+ def make_hashable(v: Any) -> Any:
255
+ # Hashable, structure-preserving form for set comparison. Lists become
256
+ # tuples, not a joined string (which would collide).
263
257
  if isinstance(v, dict):
264
- return k, Hashabledict(v)
258
+ return Hashabledict({k: make_hashable(val) for k, val in v.items()})
265
259
  if isinstance(v, list):
266
- return k, ",".join(map(str, v))
267
- return k, v
260
+ return tuple(make_hashable(i) for i in v)
261
+ return v
268
262
 
269
- current = Hashabledict({fmt_dict(k, v) for k, v in self.serialize().items()})
270
- init = Hashabledict({fmt_dict(k, v) for k, v in self.serialize(init=True).items()})
271
- return set([i[0] for i in set(current.items()) ^ set(init.items())])
263
+ current = {k: make_hashable(v) for k, v in self.serialize().items()}
264
+ init = {k: make_hashable(v) for k, v in self.serialize(init=True).items()}
265
+ return {key for key, _ in set(current.items()) ^ set(init.items())}
272
266
 
273
267
  def updates(self) -> dict[str, Any]:
274
268
  """
@@ -284,6 +278,15 @@ class Record(object):
284
278
  return {i: serialized[i] for i in diff}
285
279
  return {}
286
280
 
281
+ def _make_request(self, sub_path: str) -> Request:
282
+ return Request(
283
+ base=cat(self.endpoint.url, self.id, sub_path),
284
+ token=self.api.access_token,
285
+ http_session=self.api.http_session,
286
+ user_agent=self.api.user_agent,
287
+ proxies=self.api.proxies,
288
+ )
289
+
287
290
  def save(self) -> bool:
288
291
  """
289
292
  Save changes to an existing object.
@@ -298,10 +301,15 @@ class Record(object):
298
301
  base=self.endpoint.url,
299
302
  token=self.api.access_token,
300
303
  http_session=self.api.http_session,
301
- user_agent=self.user_agent,
304
+ user_agent=self.api.user_agent,
302
305
  proxies=self.api.proxies,
303
306
  )
304
- if r.patch(updates):
307
+ result = r.patch(updates)
308
+ if result:
309
+ # Refresh the record from the server response
310
+ if isinstance(result, dict):
311
+ self._init_cache = []
312
+ self._parse_values(result)
305
313
  return True
306
314
  return False
307
315
 
@@ -326,7 +334,7 @@ class Record(object):
326
334
  base=self.endpoint.url,
327
335
  token=self.api.access_token,
328
336
  http_session=self.api.http_session,
329
- user_agent=self.user_agent,
337
+ user_agent=self.api.user_agent,
330
338
  proxies=self.api.proxies,
331
339
  )
332
- return True if r.delete() else False
340
+ return r.delete()
@@ -29,9 +29,6 @@ class Token:
29
29
  self.encoded: str = token # Cache signed token data
30
30
  self.expires_at: datetime = expires_at
31
31
 
32
- def __str__(self) -> str:
33
- return self.encoded
34
-
35
32
  def __repr__(self) -> str:
36
33
  return f"<Token ttl={self.ttl}s>"
37
34
 
@@ -15,16 +15,16 @@ def cat(*args: Any, separator: str = "/", trailing: str = "") -> str:
15
15
 
16
16
  If an item cannot be parsed as a string, an AttributeError will be raised.
17
17
 
18
- >>> concatenate("a", "b", "c")
19
- 'a/b/c/'
20
- >>> concatenate("a", "b", "/c/", separator="")
18
+ >>> cat("a", "/b/", "c/")
21
19
  'a/b/c'
22
- >>> concatenate("a", "/b/", 1)
23
- 'a/b/1/'
24
- >>> concatenate("a", "b", "c", separator="_", trailing="_")
25
- 'a_b_c_'
20
+ >>> cat("a", 1, "b")
21
+ 'a/1/b'
22
+ >>> cat("a", "b", "c", separator="_")
23
+ 'a_b_c'
24
+ >>> cat("a", "b", "c", trailing="/")
25
+ 'a/b/c/'
26
26
  """
27
- s = separator.join([str(i).lstrip(separator).rstrip(separator) for i in args])
27
+ s = separator.join([str(i).strip(separator) for i in args if str(i)])
28
28
  if trailing:
29
29
  s += trailing
30
30
  return s
@@ -1,4 +1,5 @@
1
1
  import ipaddress
2
+ from typing import Any
2
3
 
3
4
  from pyixapi.core.response import Record
4
5
 
@@ -24,6 +25,21 @@ class Connection(Record):
24
25
  def __str__(self) -> str:
25
26
  return f"{self.id}: {self.name}"
26
27
 
28
+ def cancellation_policy(self) -> dict[str, Any]:
29
+ return self._make_request("cancellation-policy")._make_call()
30
+
31
+ def get_loa(self) -> dict[str, Any]:
32
+ return self._make_request("loa")._make_call()
33
+
34
+ def upload_loa(self, data: dict[str, Any]) -> dict[str, Any]:
35
+ return self._make_request("loa")._make_call(verb="post", data=data)
36
+
37
+ def statistics(self, **kwargs: Any) -> dict[str, Any]:
38
+ return self._make_request("statistics")._make_call(add_params=kwargs or None)
39
+
40
+ def statistics_timeseries(self, aggregate: str, **kwargs: Any) -> dict[str, Any]:
41
+ return self._make_request(f"statistics/{aggregate}/timeseries")._make_call(add_params=kwargs or None)
42
+
27
43
 
28
44
  class Contact(Record):
29
45
  """
@@ -133,6 +149,21 @@ class NetworkServiceConfig(Record):
133
149
  def __str__(self) -> str:
134
150
  return self.id
135
151
 
152
+ def cancellation_policy(self) -> dict[str, Any]:
153
+ return self._make_request("cancellation-policy")._make_call()
154
+
155
+ def peer_statistics(self, **kwargs: Any) -> dict[str, Any]:
156
+ return self._make_request("peer-statistics")._make_call(add_params=kwargs or None)
157
+
158
+ def peer_statistics_timeseries(self, aggregate: str, **kwargs: Any) -> dict[str, Any]:
159
+ return self._make_request(f"peer-statistics/{aggregate}/timeseries")._make_call(add_params=kwargs or None)
160
+
161
+ def statistics(self, **kwargs: Any) -> dict[str, Any]:
162
+ return self._make_request("statistics")._make_call(add_params=kwargs or None)
163
+
164
+ def statistics_timeseries(self, aggregate: str, **kwargs: Any) -> dict[str, Any]:
165
+ return self._make_request(f"statistics/{aggregate}/timeseries")._make_call(add_params=kwargs or None)
166
+
136
167
 
137
168
  class NetworkService(Record):
138
169
  """
@@ -143,6 +174,27 @@ class NetworkService(Record):
143
174
  def __str__(self) -> str:
144
175
  return self.id
145
176
 
177
+ def cancellation_policy(self) -> dict[str, Any]:
178
+ return self._make_request("cancellation-policy")._make_call()
179
+
180
+ def change_request(self) -> dict[str, Any]:
181
+ return self._make_request("change-request")._make_call()
182
+
183
+ def create_change_request(self, data: dict[str, Any]) -> dict[str, Any]:
184
+ return self._make_request("change-request")._make_call(verb="post", data=data)
185
+
186
+ def delete_change_request(self) -> bool:
187
+ return self._make_request("change-request")._make_call(verb="delete")
188
+
189
+ def rtt_statistics(self, **kwargs: Any) -> dict[str, Any]:
190
+ return self._make_request("rtt-statistics")._make_call(add_params=kwargs or None)
191
+
192
+ def statistics(self, **kwargs: Any) -> dict[str, Any]:
193
+ return self._make_request("statistics")._make_call(add_params=kwargs or None)
194
+
195
+ def statistics_timeseries(self, aggregate: str, **kwargs: Any) -> dict[str, Any]:
196
+ return self._make_request(f"statistics/{aggregate}/timeseries")._make_call(add_params=kwargs or None)
197
+
146
198
 
147
199
  class PoP(Record):
148
200
  """
@@ -168,6 +220,15 @@ class ProductOffering(Record):
168
220
  # Version 2 and up
169
221
 
170
222
 
223
+ class AvailabilityZone(Record):
224
+ """
225
+ An AvailabilityZone describes the physical location or zone of a resource.
226
+ """
227
+
228
+ def __str__(self) -> str:
229
+ return self.name
230
+
231
+
171
232
  class MemberJoiningRule(Record):
172
233
  """
173
234
  A MemberJoiningRule defines a rule to allow or deny access for an Account to a
@@ -212,6 +273,12 @@ class Port(Record):
212
273
  def __str__(self) -> str:
213
274
  return self.name
214
275
 
276
+ def statistics(self, **kwargs: Any) -> dict[str, Any]:
277
+ return self._make_request("statistics")._make_call(add_params=kwargs or None)
278
+
279
+ def statistics_timeseries(self, aggregate: str, **kwargs: Any) -> dict[str, Any]:
280
+ return self._make_request(f"statistics/{aggregate}/timeseries")._make_call(add_params=kwargs or None)
281
+
215
282
 
216
283
  class PortReservation(Record):
217
284
  """
@@ -226,6 +293,28 @@ class PortReservation(Record):
226
293
  def __str__(self) -> str:
227
294
  return self.id
228
295
 
296
+ def cancellation_policy(self) -> dict[str, Any]:
297
+ return self._make_request("cancellation-policy")._make_call()
298
+
299
+ def get_loa(self) -> dict[str, Any]:
300
+ return self._make_request("loa")._make_call()
301
+
302
+ def upload_loa(self, data: dict[str, Any]) -> dict[str, Any]:
303
+ return self._make_request("loa")._make_call(verb="post", data=data)
304
+
305
+
306
+ class RoutingFunction(Record):
307
+ """
308
+ A RoutingFunction is a function that provides routing capabilities within a
309
+ network service.
310
+ """
311
+
312
+ def __str__(self) -> str:
313
+ return self.id
314
+
315
+ def cancellation_policy(self) -> dict[str, Any]:
316
+ return self._make_request("cancellation-policy")._make_call()
317
+
229
318
 
230
319
  class Role(Record):
231
320
  """
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pyixapi"
3
- version = "0.2.8"
3
+ version = "0.3.0"
4
4
  description = "Python API client library for IX-API"
5
5
  authors = [{ name = "Guillaume Mazoyer", email = "oss@mazoyer.eu" }]
6
6
  readme = "README.md"
@@ -15,15 +15,11 @@ classifiers = [
15
15
  "Programming Language :: Python :: 3.13",
16
16
  "Programming Language :: Python :: 3.14",
17
17
  ]
18
- dependencies = ["PyJWT>=2.4.0,<2.13", "requests>=2.32.4,<3.0"]
18
+ dependencies = ["PyJWT>=2.4.0,<2.14", "requests>=2.32.4,<3.0"]
19
19
 
20
20
  [dependency-groups]
21
21
  dev = ["pytest", "pytest-cov", "ruff", "ty"]
22
22
 
23
- [tool.poetry]
24
- requires-poetry = ">=2.0"
25
- packages = [{ include = "pyixapi" }]
26
-
27
23
  [tool.ruff]
28
24
  line-length = 120
29
25
  exclude = [".git", ".tox", ".venv", "env", "_build", "build", "dist", "examples", "__main__.py"]
@@ -63,7 +59,7 @@ invalid-return-type = "ignore"
63
59
  unresolved-import = "ignore"
64
60
 
65
61
  [tool.pytest.ini_options]
66
- addopts = "--cov=pyixapi --cov-report=term-missing --cov-report=xml"
62
+ addopts = "--cov=pyixapi --cov-branch --cov-report=term-missing --cov-report=xml"
67
63
 
68
64
  [tool.coverage.run]
69
65
  source = ["pyixapi"]
File without changes
File without changes
File without changes