rackfish 1.0.0__py3-none-any.whl
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.
- rackfish/__init__.py +27 -0
- rackfish/client.py +609 -0
- rackfish-1.0.0.dist-info/METADATA +368 -0
- rackfish-1.0.0.dist-info/RECORD +7 -0
- rackfish-1.0.0.dist-info/WHEEL +5 -0
- rackfish-1.0.0.dist-info/licenses/LICENSE +21 -0
- rackfish-1.0.0.dist-info/top_level.txt +1 -0
rackfish/__init__.py
ADDED
@@ -0,0 +1,27 @@
|
|
1
|
+
"""
|
2
|
+
rackfish - Dynamic Redfish Client Library
|
3
|
+
|
4
|
+
A lightweight, dynamic Python client for interacting with Redfish BMC APIs.
|
5
|
+
Provides intuitive access to server hardware management through lazy-loaded
|
6
|
+
object graphs, automatic OEM property surfacing, and validated action invocation.
|
7
|
+
|
8
|
+
Example:
|
9
|
+
>>> from rackfish import RedfishClient
|
10
|
+
>>> client = RedfishClient("https://bmc.example.com", "admin", "password")
|
11
|
+
>>> root = client.connect()
|
12
|
+
>>> for system in client.Systems:
|
13
|
+
... print(system.PowerState)
|
14
|
+
>>> client.logout()
|
15
|
+
|
16
|
+
For more examples, see the documentation at:
|
17
|
+
https://github.com/thefrolov/rackfish
|
18
|
+
"""
|
19
|
+
|
20
|
+
from .client import RedfishClient, RedfishError, RedfishResource
|
21
|
+
|
22
|
+
__version__ = "1.0.0"
|
23
|
+
__author__ = "Dmitrii Frolov"
|
24
|
+
__email__ = "thefrolov@mts.ru"
|
25
|
+
__license__ = "MIT"
|
26
|
+
|
27
|
+
__all__ = ["RedfishClient", "RedfishError", "RedfishResource"]
|
rackfish/client.py
ADDED
@@ -0,0 +1,609 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import contextlib
|
4
|
+
import json
|
5
|
+
import re
|
6
|
+
import threading
|
7
|
+
from typing import Any, ClassVar, Iterable
|
8
|
+
|
9
|
+
import requests
|
10
|
+
|
11
|
+
# HTTP status codes
|
12
|
+
HTTP_OK = 200
|
13
|
+
|
14
|
+
# ---------------------------
|
15
|
+
# Utilities
|
16
|
+
# ---------------------------
|
17
|
+
|
18
|
+
|
19
|
+
def _is_identifier(key: str) -> bool:
|
20
|
+
"""Return True if key is a valid Python identifier (attr-safe)."""
|
21
|
+
return re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", key) is not None
|
22
|
+
|
23
|
+
|
24
|
+
def _dtype_matches(value: Any, dtype: str) -> bool:
|
25
|
+
"""Basic datatype check against Redfish ActionInfo DataType values."""
|
26
|
+
# Common DataType values in Redfish ActionInfo:
|
27
|
+
# "String", "Integer", "Number", "Boolean", "Array", "Object"
|
28
|
+
m = {
|
29
|
+
"String": lambda v: isinstance(v, str),
|
30
|
+
"Integer": lambda v: isinstance(v, int) and not isinstance(v, bool),
|
31
|
+
"Number": lambda v: isinstance(v, (int, float)) and not isinstance(v, bool),
|
32
|
+
"Boolean": lambda v: isinstance(v, bool),
|
33
|
+
"Array": lambda v: isinstance(v, list),
|
34
|
+
"Object": lambda v: isinstance(v, dict),
|
35
|
+
# Some implementations use "Password", "Enumeration"—treat like string
|
36
|
+
# unless AllowableValues present
|
37
|
+
"Password": lambda v: isinstance(v, str),
|
38
|
+
"Enumeration": lambda _v: True, # validated via AllowableValues if present
|
39
|
+
# Fallback: accept anything
|
40
|
+
}
|
41
|
+
fn = m.get(dtype, lambda _v: True)
|
42
|
+
return fn(value)
|
43
|
+
|
44
|
+
|
45
|
+
def _safe_join(base: str, path: str) -> str:
|
46
|
+
if path.startswith("http"):
|
47
|
+
return path
|
48
|
+
return base.rstrip("/") + "/" + path.lstrip("/")
|
49
|
+
|
50
|
+
|
51
|
+
# ---------------------------
|
52
|
+
# HTTP Client
|
53
|
+
# ---------------------------
|
54
|
+
|
55
|
+
|
56
|
+
class RedfishError(Exception):
|
57
|
+
pass
|
58
|
+
|
59
|
+
|
60
|
+
class RedfishClient:
|
61
|
+
"""
|
62
|
+
Redfish HTTP client with session/basic auth and convenience verbs.
|
63
|
+
base_url: e.g. "https://bmc.example.com" (with or without /redfish[/v1])
|
64
|
+
"""
|
65
|
+
|
66
|
+
def __init__(
|
67
|
+
self,
|
68
|
+
base_url: str,
|
69
|
+
username: str | None = None,
|
70
|
+
password: str | None = None,
|
71
|
+
use_session: bool = True,
|
72
|
+
verify_ssl: bool = True,
|
73
|
+
timeout: int = 30,
|
74
|
+
default_headers: dict[str, str] | None = None,
|
75
|
+
):
|
76
|
+
base = base_url.rstrip("/")
|
77
|
+
# if not base.endswith("/redfish/v1"):
|
78
|
+
# if base.endswith("/redfish"):
|
79
|
+
# base = base + "/v1"
|
80
|
+
# else:
|
81
|
+
# base = base + "/redfish/v1"
|
82
|
+
|
83
|
+
self.base_url = base
|
84
|
+
self.username = username
|
85
|
+
self.password = password
|
86
|
+
self.use_session_auth = use_session
|
87
|
+
self.timeout = timeout
|
88
|
+
|
89
|
+
self._http = requests.Session()
|
90
|
+
self._http.verify = verify_ssl
|
91
|
+
self._http.headers.update(default_headers or {"Accept": "application/json"})
|
92
|
+
if username and password and not use_session:
|
93
|
+
self._http.auth = (username, password)
|
94
|
+
|
95
|
+
self._session_token: str | None = None
|
96
|
+
self._session_uri: str | None = None
|
97
|
+
self._root: RedfishResource | None = None
|
98
|
+
self._lock = threading.RLock()
|
99
|
+
|
100
|
+
# ---- auth ----
|
101
|
+
|
102
|
+
def login(self) -> None:
|
103
|
+
if not (self.username and self.password):
|
104
|
+
raise RedfishError("Username/password required for session login")
|
105
|
+
|
106
|
+
url = _safe_join(self.base_url, "/redfish/v1/SessionService/Sessions")
|
107
|
+
resp = self._http.post(
|
108
|
+
url,
|
109
|
+
json={"UserName": self.username, "Password": self.password},
|
110
|
+
timeout=self.timeout,
|
111
|
+
)
|
112
|
+
if resp.status_code not in (200, 201):
|
113
|
+
raise RedfishError(f"Login failed: {resp.status_code} {resp.text}")
|
114
|
+
|
115
|
+
token = resp.headers.get("X-Auth-Token")
|
116
|
+
loc = resp.headers.get("Location")
|
117
|
+
if token:
|
118
|
+
self._http.headers["X-Auth-Token"] = token
|
119
|
+
self._session_token = token
|
120
|
+
if loc:
|
121
|
+
self._session_uri = _safe_join(self.base_url, loc)
|
122
|
+
|
123
|
+
def logout(self) -> None:
|
124
|
+
try:
|
125
|
+
if self._session_uri and self._session_token:
|
126
|
+
self.delete(self._session_uri)
|
127
|
+
finally:
|
128
|
+
self._http.close()
|
129
|
+
self._session_token = None
|
130
|
+
self._session_uri = None
|
131
|
+
self._root = None
|
132
|
+
|
133
|
+
# ---- HTTP verbs ----
|
134
|
+
|
135
|
+
def get(self, path: str) -> dict[str, Any]:
|
136
|
+
url = _safe_join(self.base_url, path) if not path.startswith("http") else path
|
137
|
+
resp = self._http.get(url, timeout=self.timeout)
|
138
|
+
if resp.status_code != HTTP_OK:
|
139
|
+
raise RedfishError(f"GET {url} -> {resp.status_code} {resp.text}")
|
140
|
+
if not resp.content:
|
141
|
+
return {}
|
142
|
+
try:
|
143
|
+
return resp.json()
|
144
|
+
except Exception:
|
145
|
+
raise RedfishError(f"GET {url} returned non-JSON") from None
|
146
|
+
|
147
|
+
def post(self, path: str, data: dict[str, Any] | None = None) -> dict[str, Any] | None:
|
148
|
+
url = _safe_join(self.base_url, path) if not path.startswith("http") else path
|
149
|
+
resp = self._http.post(url, json=data or {}, timeout=self.timeout)
|
150
|
+
if resp.status_code not in (200, 201, 202, 204):
|
151
|
+
raise RedfishError(f"POST {url} -> {resp.status_code} {resp.text}")
|
152
|
+
if resp.status_code in (200, 201) and resp.content:
|
153
|
+
try:
|
154
|
+
return resp.json()
|
155
|
+
except Exception:
|
156
|
+
return None
|
157
|
+
return None
|
158
|
+
|
159
|
+
def patch(self, path: str, data: dict[str, Any]) -> None:
|
160
|
+
url = _safe_join(self.base_url, path) if not path.startswith("http") else path
|
161
|
+
resp = self._http.patch(url, json=data, timeout=self.timeout)
|
162
|
+
if resp.status_code not in (200, 204):
|
163
|
+
raise RedfishError(f"PATCH {url} -> {resp.status_code} {resp.text}")
|
164
|
+
|
165
|
+
def delete(self, path: str) -> None:
|
166
|
+
url = _safe_join(self.base_url, path) if not path.startswith("http") else path
|
167
|
+
resp = self._http.delete(url, timeout=self.timeout)
|
168
|
+
if resp.status_code not in (200, 204):
|
169
|
+
raise RedfishError(f"DELETE {url} -> {resp.status_code} {resp.text}")
|
170
|
+
|
171
|
+
# ---- navigation ----
|
172
|
+
|
173
|
+
def connect(self) -> RedfishResource:
|
174
|
+
with self._lock:
|
175
|
+
if (
|
176
|
+
self.use_session_auth
|
177
|
+
and self.username
|
178
|
+
and self.password
|
179
|
+
and not self._session_token
|
180
|
+
):
|
181
|
+
self.login()
|
182
|
+
root_json = self.get(self.base_url + "/redfish/v1")
|
183
|
+
self._root = RedfishResource(self, path=self.base_url, data=root_json, fetched=True)
|
184
|
+
return self._root
|
185
|
+
|
186
|
+
@property
|
187
|
+
def root(self) -> RedfishResource:
|
188
|
+
if self._root is None:
|
189
|
+
return self.connect()
|
190
|
+
return self._root
|
191
|
+
|
192
|
+
def __getattr__(self, name: str) -> Any:
|
193
|
+
# Proxy unknown attributes to root (e.g., client.Systems)
|
194
|
+
return getattr(self.root, name)
|
195
|
+
|
196
|
+
|
197
|
+
# ---------------------------
|
198
|
+
# Resource graph
|
199
|
+
# ---------------------------
|
200
|
+
|
201
|
+
|
202
|
+
class RedfishResource:
|
203
|
+
"""
|
204
|
+
Generic Redfish resource.
|
205
|
+
- data mapping recursively creates nested objects
|
206
|
+
- links (@odata.id) are lazy and fetched on first access
|
207
|
+
- collections support iteration over Members
|
208
|
+
- Actions become callable methods; if ActionInfo present, inputs are validated
|
209
|
+
"""
|
210
|
+
|
211
|
+
# Keys considered "meta" that won't be turned into normal attributes
|
212
|
+
_META_KEYS: ClassVar[set[str]] = {
|
213
|
+
"@odata.id",
|
214
|
+
"@odata.type",
|
215
|
+
"@odata.context",
|
216
|
+
"@odata.etag",
|
217
|
+
}
|
218
|
+
|
219
|
+
def __init__(
|
220
|
+
self,
|
221
|
+
client: RedfishClient,
|
222
|
+
path: str | None = None,
|
223
|
+
data: dict[str, Any] | None = None,
|
224
|
+
fetched: bool = False,
|
225
|
+
):
|
226
|
+
object.__setattr__(self, "_client", client)
|
227
|
+
object.__setattr__(self, "_path", path) # absolute or relative
|
228
|
+
object.__setattr__(self, "_fetched", fetched)
|
229
|
+
object.__setattr__(self, "_raw", data or {})
|
230
|
+
object.__setattr__(self, "_is_collection", False)
|
231
|
+
|
232
|
+
# Internal cache for generated action validators: name -> schema
|
233
|
+
object.__setattr__(self, "_action_info_cache", {})
|
234
|
+
|
235
|
+
if data is None and path and not fetched:
|
236
|
+
# Pure link stub; defer GET until used
|
237
|
+
return
|
238
|
+
|
239
|
+
# If we have data, map to attributes
|
240
|
+
self._hydrate(self._raw)
|
241
|
+
|
242
|
+
# ---- core mapping ----
|
243
|
+
|
244
|
+
def _hydrate(self, obj: dict[str, Any]) -> None:
|
245
|
+
# Path inference from data if needed
|
246
|
+
if not self._path and "@odata.id" in obj:
|
247
|
+
object.__setattr__(self, "_path", obj["@odata.id"])
|
248
|
+
|
249
|
+
# Collection detection
|
250
|
+
if isinstance(obj.get("Members"), list):
|
251
|
+
object.__setattr__(self, "_is_collection", True)
|
252
|
+
|
253
|
+
# First pass: map properties (including nested dicts/lists)
|
254
|
+
for key, value in obj.items():
|
255
|
+
if key in self._META_KEYS:
|
256
|
+
continue
|
257
|
+
self._assign_property(key, value)
|
258
|
+
|
259
|
+
# Second pass: surface OEM child objects and actions to main object
|
260
|
+
oem = obj.get("Oem", {})
|
261
|
+
if isinstance(oem, dict):
|
262
|
+
self._surface_oem_content(oem)
|
263
|
+
|
264
|
+
# Third pass: surface Links child objects to main object
|
265
|
+
links = obj.get("Links", {})
|
266
|
+
if isinstance(links, dict):
|
267
|
+
self._surface_links_content(links)
|
268
|
+
|
269
|
+
# Fourth pass: generate action methods (including OEM actions)
|
270
|
+
actions = obj.get("Actions", {})
|
271
|
+
if isinstance(actions, dict):
|
272
|
+
self._install_actions(actions)
|
273
|
+
|
274
|
+
def _assign_property(self, key: str, value: Any) -> None:
|
275
|
+
"""
|
276
|
+
Convert value into nested RedfishResource(s) where appropriate and attach as attribute or
|
277
|
+
make accessible via item access if key is not a valid identifier.
|
278
|
+
"""
|
279
|
+
converted = self._convert(value)
|
280
|
+
if _is_identifier(key):
|
281
|
+
object.__setattr__(self, key, converted)
|
282
|
+
else:
|
283
|
+
# Put non-identifier keys into the raw dict; accessible via __getitem__
|
284
|
+
self._raw[key] = value # keep exact original for special keys
|
285
|
+
# Try to surface helper for AllowableValues patterns
|
286
|
+
# e.g., "BootSourceOverrideTarget@Redfish.AllowableValues"
|
287
|
+
# Expose via helper .get_allowable_values("BootSourceOverrideTarget")
|
288
|
+
# Keep an easy view of JSON primitives when needed
|
289
|
+
# (we already keep _raw)
|
290
|
+
|
291
|
+
def _surface_oem_content(self, oem: dict[str, Any]) -> None:
|
292
|
+
"""
|
293
|
+
Surface vendor-specific OEM child objects and properties to main object.
|
294
|
+
Avoids name collisions by checking if attribute already exists.
|
295
|
+
"""
|
296
|
+
for _vendor, vendor_data in oem.items():
|
297
|
+
if not isinstance(vendor_data, dict):
|
298
|
+
continue
|
299
|
+
for key, value in vendor_data.items():
|
300
|
+
# Skip if key already exists or is not a valid identifier
|
301
|
+
if not _is_identifier(key) or hasattr(self, key):
|
302
|
+
continue
|
303
|
+
# Skip action keys (handled separately)
|
304
|
+
if key.startswith("#"):
|
305
|
+
continue
|
306
|
+
# Surface the OEM child object/property to main object
|
307
|
+
converted = self._convert(value)
|
308
|
+
object.__setattr__(self, key, converted)
|
309
|
+
|
310
|
+
def _surface_links_content(self, links: dict[str, Any]) -> None:
|
311
|
+
"""
|
312
|
+
Surface Links child objects to main object for easier navigation.
|
313
|
+
Avoids name collisions by checking if attribute already exists.
|
314
|
+
"""
|
315
|
+
for key, value in links.items():
|
316
|
+
# Skip meta keys and existing attributes
|
317
|
+
if key in self._META_KEYS or not _is_identifier(key) or hasattr(self, key):
|
318
|
+
continue
|
319
|
+
# Surface the linked resource to main object
|
320
|
+
converted = self._convert(value)
|
321
|
+
object.__setattr__(self, key, converted)
|
322
|
+
|
323
|
+
def _convert(self, value: Any) -> Any:
|
324
|
+
"""
|
325
|
+
Recursively convert dicts/lists:
|
326
|
+
- If dict has only @odata.id -> link stub (lazy)
|
327
|
+
- If dict contains '@odata.id' + other keys -> embedded object
|
328
|
+
(also lazy to avoid deep recursion)
|
329
|
+
- Lists -> convert each element
|
330
|
+
- Primitives -> return as-is
|
331
|
+
"""
|
332
|
+
if isinstance(value, dict):
|
333
|
+
if set(value.keys()) == {"@odata.id"}:
|
334
|
+
return RedfishResource(
|
335
|
+
self._client, path=value["@odata.id"], data=None, fetched=False
|
336
|
+
)
|
337
|
+
# Even if it has @odata.id plus fields, defer hydration to avoid recursion depth issues
|
338
|
+
# Store the data but don't hydrate until first access
|
339
|
+
return RedfishResource(
|
340
|
+
self._client, path=value.get("@odata.id"), data=value, fetched=False
|
341
|
+
)
|
342
|
+
if isinstance(value, list):
|
343
|
+
out = []
|
344
|
+
for elem in value:
|
345
|
+
out.append(self._convert(elem))
|
346
|
+
return out
|
347
|
+
return value
|
348
|
+
|
349
|
+
# ---- lazy load ----
|
350
|
+
|
351
|
+
def _ensure_fetched(self) -> None:
|
352
|
+
if self._fetched:
|
353
|
+
return
|
354
|
+
# If we have data already (embedded resource), just hydrate it without fetching
|
355
|
+
if self._raw:
|
356
|
+
object.__setattr__(self, "_fetched", True)
|
357
|
+
object.__setattr__(self, "_is_collection", isinstance(self._raw.get("Members"), list))
|
358
|
+
self._hydrate(self._raw)
|
359
|
+
return
|
360
|
+
# Otherwise, fetch from server if we have a path
|
361
|
+
if not self._path:
|
362
|
+
return
|
363
|
+
data = self._client.get(self._path)
|
364
|
+
object.__setattr__(self, "_raw", data)
|
365
|
+
object.__setattr__(self, "_fetched", True)
|
366
|
+
object.__setattr__(self, "_is_collection", isinstance(data.get("Members"), list))
|
367
|
+
self._hydrate(data)
|
368
|
+
|
369
|
+
# ---- dict-like access for special keys ----
|
370
|
+
|
371
|
+
def __getitem__(self, key: str) -> Any:
|
372
|
+
self._ensure_fetched()
|
373
|
+
return self._raw[key]
|
374
|
+
|
375
|
+
def get(self, key: str, default: Any = None) -> Any:
|
376
|
+
self._ensure_fetched()
|
377
|
+
return self._raw.get(key, default)
|
378
|
+
|
379
|
+
# ---- attribute access overrides ----
|
380
|
+
|
381
|
+
def __getattr__(self, name: str) -> Any:
|
382
|
+
# Trigger lazy fetch for link stubs on first unknown attribute access
|
383
|
+
self._ensure_fetched()
|
384
|
+
try:
|
385
|
+
return object.__getattribute__(self, name)
|
386
|
+
except AttributeError:
|
387
|
+
# Not an attribute; maybe a JSON property with non-identifier key
|
388
|
+
if name in self._raw:
|
389
|
+
return self._raw[name]
|
390
|
+
raise
|
391
|
+
|
392
|
+
def __setattr__(self, name: str, value: Any) -> None:
|
393
|
+
# Allow setting simple existing properties via PATCH
|
394
|
+
if name.startswith("_"):
|
395
|
+
object.__setattr__(self, name, value)
|
396
|
+
return
|
397
|
+
|
398
|
+
# If not yet fetched (link stub), fetch first so we know what's writable
|
399
|
+
self._ensure_fetched()
|
400
|
+
|
401
|
+
if name in self._raw and not isinstance(self._raw[name], (dict, list)):
|
402
|
+
# PATCH only simple properties by default; complex updates via .patch()
|
403
|
+
self._client.patch(self._path, {name: value})
|
404
|
+
self._raw[name] = value
|
405
|
+
object.__setattr__(self, name, value)
|
406
|
+
else:
|
407
|
+
# Set as a Python-side attribute (or ask user to use .patch for complex)
|
408
|
+
object.__setattr__(self, name, value)
|
409
|
+
|
410
|
+
# ---- collection protocols ----
|
411
|
+
|
412
|
+
def __iter__(self) -> Iterable[RedfishResource]:
|
413
|
+
self._ensure_fetched()
|
414
|
+
if not self._is_collection:
|
415
|
+
raise TypeError(f"Resource at {self.identity} is not a collection")
|
416
|
+
members = self._raw.get("Members", [])
|
417
|
+
for m in members:
|
418
|
+
if isinstance(m, dict) and "@odata.id" in m:
|
419
|
+
yield RedfishResource(self._client, path=m["@odata.id"], data=None, fetched=False)
|
420
|
+
else:
|
421
|
+
# very rare, but support raw members
|
422
|
+
yield RedfishResource(self._client, data=m, fetched=True)
|
423
|
+
|
424
|
+
def __len__(self) -> int:
|
425
|
+
self._ensure_fetched()
|
426
|
+
if not self._is_collection:
|
427
|
+
raise TypeError("Resource is not a collection")
|
428
|
+
return len(self._raw.get("Members", []))
|
429
|
+
|
430
|
+
# ---- CRUD convenience ----
|
431
|
+
|
432
|
+
@property
|
433
|
+
def path(self) -> str | None:
|
434
|
+
return self._path
|
435
|
+
|
436
|
+
@property
|
437
|
+
def identity(self) -> str:
|
438
|
+
self._ensure_fetched()
|
439
|
+
return self._raw.get("Id") or self._raw.get("Name") or (self._path or "<unknown>")
|
440
|
+
|
441
|
+
@property
|
442
|
+
def odata_type(self) -> str:
|
443
|
+
self._ensure_fetched()
|
444
|
+
return self._raw.get("@odata.type", "Resource")
|
445
|
+
|
446
|
+
def refresh(self) -> RedfishResource:
|
447
|
+
self._ensure_fetched()
|
448
|
+
data = self._client.get(self._path)
|
449
|
+
object.__setattr__(self, "_raw", data)
|
450
|
+
# reset attributes (re-hydrate). Start clean:
|
451
|
+
for k in list(self.__dict__.keys()):
|
452
|
+
if not k.startswith("_") and hasattr(self, k):
|
453
|
+
with contextlib.suppress(Exception):
|
454
|
+
delattr(self, k)
|
455
|
+
self._hydrate(data)
|
456
|
+
return self
|
457
|
+
|
458
|
+
def patch(self, updates: dict[str, Any]) -> None:
|
459
|
+
if not self._path:
|
460
|
+
raise RedfishError("PATCH requires a resource path")
|
461
|
+
self._client.patch(self._path, updates)
|
462
|
+
self.refresh()
|
463
|
+
|
464
|
+
def delete(self) -> None:
|
465
|
+
if not self._path:
|
466
|
+
raise RedfishError("DELETE requires a resource path")
|
467
|
+
self._client.delete(self._path)
|
468
|
+
|
469
|
+
def create(self, new_data: dict[str, Any]) -> RedfishResource:
|
470
|
+
self._ensure_fetched()
|
471
|
+
if not self._is_collection:
|
472
|
+
raise RedfishError("create() only valid on collection resources")
|
473
|
+
resp = self._client.post(self._path, new_data)
|
474
|
+
# Some BMCs return the new object body, others return Location only
|
475
|
+
# (handled by caller via follow-up get)
|
476
|
+
if resp and "@odata.id" in resp:
|
477
|
+
return RedfishResource(self._client, path=resp["@odata.id"], data=resp, fetched=True)
|
478
|
+
# Try reading collection again or follow Location header is not exposed
|
479
|
+
# here; user may .refresh()
|
480
|
+
return self.refresh() # so caller can find the new member
|
481
|
+
|
482
|
+
# ---- Actions handling ----
|
483
|
+
|
484
|
+
def _install_actions(self, actions: dict[str, Any]) -> None:
|
485
|
+
# Standard actions and OEM nested under "Oem"
|
486
|
+
for k, v in actions.items():
|
487
|
+
if k == "Oem" and isinstance(v, dict):
|
488
|
+
# OEM -> vendors e.g. {"Huawei": {"#ComputerSystem.FruControl": {...}}}
|
489
|
+
for _vendor, vendor_actions in v.items():
|
490
|
+
if not isinstance(vendor_actions, dict):
|
491
|
+
continue
|
492
|
+
for ak, av in vendor_actions.items():
|
493
|
+
if ak.startswith("#"):
|
494
|
+
self._bind_action(ak, av)
|
495
|
+
elif k.startswith("#"):
|
496
|
+
self._bind_action(k, v)
|
497
|
+
|
498
|
+
def _bind_action(self, action_name_with_hash: str, info: dict[str, Any]) -> None:
|
499
|
+
"""
|
500
|
+
Create a method on this instance for the given action.
|
501
|
+
If @Redfish.ActionInfo is present, we pull parameter schema and validate kwargs on call.
|
502
|
+
"""
|
503
|
+
target = info.get("target")
|
504
|
+
action_info_uri = info.get("@Redfish.ActionInfo")
|
505
|
+
|
506
|
+
clean_name = action_name_with_hash.lstrip("#").split(".")[-1] # e.g., "Reset"
|
507
|
+
validator = None
|
508
|
+
sig_hint = None
|
509
|
+
|
510
|
+
if action_info_uri:
|
511
|
+
try:
|
512
|
+
schema = self._client.get(action_info_uri)
|
513
|
+
validator, sig_hint = self._compile_action_validator(schema)
|
514
|
+
self._action_info_cache[clean_name] = schema
|
515
|
+
except Exception:
|
516
|
+
# Best-effort; if fetch fails, still expose action without validation
|
517
|
+
validator = None
|
518
|
+
|
519
|
+
def _action_method(this: RedfishResource, **kwargs):
|
520
|
+
if not target:
|
521
|
+
raise RedfishError(f"Action '{clean_name}' has no target")
|
522
|
+
if validator:
|
523
|
+
validator(kwargs)
|
524
|
+
# POST to action target with kwargs (or {} if none)
|
525
|
+
return this._client.post(target, data=(kwargs or {}))
|
526
|
+
|
527
|
+
# Attach __name__ for nicer repr; include signature hint as doc
|
528
|
+
_action_method.__name__ = clean_name
|
529
|
+
doc_hint = f"Dynamic Redfish action {clean_name}"
|
530
|
+
if sig_hint:
|
531
|
+
doc_hint += f" | params: {sig_hint}"
|
532
|
+
_action_method.__doc__ = doc_hint
|
533
|
+
|
534
|
+
# Bind to instance
|
535
|
+
object.__setattr__(self, clean_name, _action_method.__get__(self, RedfishResource))
|
536
|
+
|
537
|
+
def _compile_action_validator(self, schema: dict[str, Any]):
|
538
|
+
"""
|
539
|
+
Build a validator function from ActionInfo schema.
|
540
|
+
Expected shape:
|
541
|
+
{
|
542
|
+
"Parameters": [
|
543
|
+
{"Name": "ResetType", "Required": True, "DataType": "String",
|
544
|
+
"AllowableValues": ["On", "ForceOff", ...]}
|
545
|
+
]
|
546
|
+
}
|
547
|
+
Returns (validator_callable, signature_hint_string)
|
548
|
+
"""
|
549
|
+
params = schema.get("Parameters") or schema.get("parameters") or []
|
550
|
+
expected = {p.get("Name"): p for p in params if isinstance(p, dict) and p.get("Name")}
|
551
|
+
|
552
|
+
def validator(kwargs: dict[str, Any]) -> None:
|
553
|
+
# required
|
554
|
+
missing = [n for n, p in expected.items() if p.get("Required") and n not in kwargs]
|
555
|
+
if missing:
|
556
|
+
raise RedfishError(f"Missing required action parameter(s): {', '.join(missing)}")
|
557
|
+
|
558
|
+
# unknown
|
559
|
+
unknown = [k for k in kwargs if k not in expected]
|
560
|
+
if unknown:
|
561
|
+
raise RedfishError(f"Unknown action parameter(s): {', '.join(unknown)}")
|
562
|
+
|
563
|
+
# types & allowable values
|
564
|
+
for name, p in expected.items():
|
565
|
+
if name not in kwargs:
|
566
|
+
continue
|
567
|
+
val = kwargs[name]
|
568
|
+
dtype = p.get("DataType")
|
569
|
+
if dtype and not _dtype_matches(val, dtype):
|
570
|
+
raise RedfishError(
|
571
|
+
f"Parameter '{name}' expects {dtype}, got {type(val).__name__}"
|
572
|
+
)
|
573
|
+
allow = p.get("AllowableValues")
|
574
|
+
if allow and val not in allow:
|
575
|
+
raise RedfishError(f"Parameter '{name}' must be one of {allow}; got '{val}'")
|
576
|
+
|
577
|
+
# Signature hint string (human-readable)
|
578
|
+
parts = []
|
579
|
+
for n, p in expected.items():
|
580
|
+
t = p.get("DataType") or "Any"
|
581
|
+
req = "required" if p.get("Required") else "optional"
|
582
|
+
allow = p.get("AllowableValues")
|
583
|
+
if allow:
|
584
|
+
parts.append(f"{n}:{t} {req} in {allow}")
|
585
|
+
else:
|
586
|
+
parts.append(f"{n}:{t} {req}")
|
587
|
+
hint = ", ".join(parts) if parts else "no parameters"
|
588
|
+
|
589
|
+
return validator, hint
|
590
|
+
|
591
|
+
# ---- helpers ----
|
592
|
+
|
593
|
+
def get_allowable_values(self, prop_name: str) -> list[Any] | None:
|
594
|
+
"""
|
595
|
+
Return AllowableValues list for a property if present as '<prop>@Redfish.AllowableValues'.
|
596
|
+
"""
|
597
|
+
self._ensure_fetched()
|
598
|
+
key = f"{prop_name}@Redfish.AllowableValues"
|
599
|
+
return self._raw.get(key)
|
600
|
+
|
601
|
+
def to_dict(self) -> dict[str, Any]:
|
602
|
+
"""Return the raw JSON (fetched on demand)."""
|
603
|
+
self._ensure_fetched()
|
604
|
+
return json.loads(json.dumps(self._raw)) # deep copy
|
605
|
+
|
606
|
+
def __repr__(self) -> str:
|
607
|
+
t = self._raw.get("@odata.type", "Resource")
|
608
|
+
ident = self._raw.get("Id") or self._raw.get("Name") or (self._path or "?")
|
609
|
+
return f"<RedfishResource {t} ({ident})>"
|
@@ -0,0 +1,368 @@
|
|
1
|
+
Metadata-Version: 2.4
|
2
|
+
Name: rackfish
|
3
|
+
Version: 1.0.0
|
4
|
+
Summary: A lightweight, dynamic Python client for Redfish BMC APIs
|
5
|
+
Author-email: Dmitrii Frolov <thefrolov@mts.ru>
|
6
|
+
License: MIT
|
7
|
+
Project-URL: Homepage, https://github.com/thefrolov/rackfish
|
8
|
+
Project-URL: Documentation, https://github.com/thefrolov/rackfish/blob/main/docs/INDEX.md
|
9
|
+
Project-URL: Repository, https://github.com/thefrolov/rackfish
|
10
|
+
Project-URL: Issues, https://github.com/thefrolov/rackfish/issues
|
11
|
+
Project-URL: Changelog, https://github.com/thefrolov/rackfish/blob/main/CHANGELOG.md
|
12
|
+
Keywords: redfish,bmc,ipmi,server-management,hardware-management,datacenter,ilo,idrac,server
|
13
|
+
Classifier: Development Status :: 5 - Production/Stable
|
14
|
+
Classifier: Intended Audience :: Developers
|
15
|
+
Classifier: Intended Audience :: System Administrators
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
17
|
+
Classifier: Operating System :: OS Independent
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
19
|
+
Classifier: Programming Language :: Python :: 3.8
|
20
|
+
Classifier: Programming Language :: Python :: 3.9
|
21
|
+
Classifier: Programming Language :: Python :: 3.10
|
22
|
+
Classifier: Programming Language :: Python :: 3.11
|
23
|
+
Classifier: Programming Language :: Python :: 3.12
|
24
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
25
|
+
Classifier: Topic :: System :: Hardware
|
26
|
+
Classifier: Topic :: System :: Systems Administration
|
27
|
+
Requires-Python: >=3.8
|
28
|
+
Description-Content-Type: text/markdown
|
29
|
+
License-File: LICENSE
|
30
|
+
Requires-Dist: requests>=2.25.0
|
31
|
+
Provides-Extra: dev
|
32
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
33
|
+
Requires-Dist: pytest-cov>=4.0; extra == "dev"
|
34
|
+
Requires-Dist: black>=23.0; extra == "dev"
|
35
|
+
Requires-Dist: ruff>=0.1.0; extra == "dev"
|
36
|
+
Requires-Dist: mypy>=1.0; extra == "dev"
|
37
|
+
Dynamic: license-file
|
38
|
+
|
39
|
+
# rackfish - Dynamic Redfish Client
|
40
|
+
|
41
|
+
[](https://pypi.org/project/rackfish/)
|
42
|
+
[](https://pypi.org/project/rackfish/)
|
43
|
+
[](https://opensource.org/licenses/MIT)
|
44
|
+
[](https://github.com/yourusername/rackfish/actions)
|
45
|
+
[](https://codecov.io/gh/yourusername/rackfish)
|
46
|
+
[](https://github.com/psf/black)
|
47
|
+
|
48
|
+
A lightweight, dynamic Python client for interacting with Redfish BMC (Baseboard Management Controller) APIs. Provides intuitive access to server hardware management through lazy-loaded object graphs, automatic OEM property surfacing, and validated action invocation.
|
49
|
+
|
50
|
+
## 🎯 Why rackfish?
|
51
|
+
|
52
|
+
- 🚀 **Zero Dependencies** (except `requests`) - Minimal footprint
|
53
|
+
- ⚡ **Lazy Loading** - Resources fetched on-demand for performance
|
54
|
+
- 🎨 **Pythonic Interface** - JSON properties become Python attributes
|
55
|
+
- 🔧 **OEM Support** - Vendor extensions (Huawei, Dell, HPE) automatically accessible
|
56
|
+
- 🔗 **Smart Navigation** - Related resources directly navigable via Links
|
57
|
+
- ✅ **Action Validation** - Parameter validation using ActionInfo schemas
|
58
|
+
- 📚 **Collection Support** - Iterate Redfish collections naturally
|
59
|
+
- 🔐 **Flexible Auth** - Session tokens or Basic authentication
|
60
|
+
|
61
|
+
## Features
|
62
|
+
|
63
|
+
- **Zero Dependencies** (except `requests`) - Minimal footprint
|
64
|
+
- **Lazy Loading** - Resources fetched on-demand for performance
|
65
|
+
- **Dynamic Attributes** - JSON properties become Python attributes
|
66
|
+
- **OEM Surfacing** - Vendor extensions automatically accessible
|
67
|
+
- **Links Surfacing** - Related resources directly navigable
|
68
|
+
- **Action Validation** - Parameter validation using ActionInfo schemas
|
69
|
+
- **Collection Support** - Iterate Redfish collections naturally
|
70
|
+
- **Session & Basic Auth** - Flexible authentication options
|
71
|
+
|
72
|
+
## Installation
|
73
|
+
|
74
|
+
### From PyPI (recommended)
|
75
|
+
|
76
|
+
```bash
|
77
|
+
pip install rackfish
|
78
|
+
```
|
79
|
+
|
80
|
+
### From source
|
81
|
+
|
82
|
+
```bash
|
83
|
+
git clone https://github.com/yourusername/rackfish.git
|
84
|
+
cd rackfish
|
85
|
+
pip install -e .
|
86
|
+
```
|
87
|
+
|
88
|
+
### Development installation
|
89
|
+
|
90
|
+
```bash
|
91
|
+
pip install -e ".[dev]"
|
92
|
+
```
|
93
|
+
|
94
|
+
## Quick Start
|
95
|
+
|
96
|
+
```python
|
97
|
+
from rackfish import RedfishClient
|
98
|
+
|
99
|
+
# Connect to BMC
|
100
|
+
client = RedfishClient("https://bmc.example.com", "admin", "password",
|
101
|
+
use_session=True, verify_ssl=False)
|
102
|
+
root = client.connect()
|
103
|
+
|
104
|
+
# Power control
|
105
|
+
system = next(iter(client.Systems))
|
106
|
+
system.Reset(ResetType="GracefulRestart")
|
107
|
+
|
108
|
+
# Access OEM properties (auto-surfaced)
|
109
|
+
if hasattr(system, "BootMode"):
|
110
|
+
print(f"Boot Mode: {system.BootMode}")
|
111
|
+
|
112
|
+
# Navigate linked resources
|
113
|
+
for chassis in system.Chassis:
|
114
|
+
print(f"Chassis: {chassis.Name}")
|
115
|
+
|
116
|
+
# Logout
|
117
|
+
client.logout()
|
118
|
+
```
|
119
|
+
|
120
|
+
## Documentation
|
121
|
+
|
122
|
+
- **[docs/EXAMPLES.md](docs/EXAMPLES.md)** - Comprehensive usage examples for all common Redfish operations
|
123
|
+
- **[docs/USE_CASES.md](docs/USE_CASES.md)** - Complete index of 150+ supported use cases
|
124
|
+
- **[docs/OEM_LINKS_SURFACING.md](docs/OEM_LINKS_SURFACING.md)** - Details on automatic OEM and Links surfacing
|
125
|
+
- **[docs/TESTS.md](docs/TESTS.md)** - Test suite documentation
|
126
|
+
- **[docs/INDEX.md](docs/INDEX.md)** - Master navigation document
|
127
|
+
- **[CHANGELOG.md](CHANGELOG.md)** - Version history and changes
|
128
|
+
- **[CONTRIBUTING.md](CONTRIBUTING.md)** - How to contribute
|
129
|
+
|
130
|
+
## Common Use Cases
|
131
|
+
|
132
|
+
### User Management
|
133
|
+
```python
|
134
|
+
accounts = client.AccountService.Accounts
|
135
|
+
new_user = accounts.create({"UserName": "operator", "Password": "pass", "RoleId": "Operator"})
|
136
|
+
new_user.RoleId = "Administrator"
|
137
|
+
new_user.delete()
|
138
|
+
```
|
139
|
+
|
140
|
+
### Storage Management
|
141
|
+
```python
|
142
|
+
storage = next(iter(client.Systems)).Storage[0]
|
143
|
+
volume = storage.Volumes.create({"Name": "DataVol", "CapacityBytes": 500*1024**3})
|
144
|
+
```
|
145
|
+
|
146
|
+
### Network Configuration
|
147
|
+
```python
|
148
|
+
port = client.Managers[0].EthernetInterfaces[0]
|
149
|
+
port.patch({"IPv4Addresses": [{"Address": "192.168.1.100", "SubnetMask": "255.255.255.0"}]})
|
150
|
+
```
|
151
|
+
|
152
|
+
### Event Subscriptions
|
153
|
+
```python
|
154
|
+
subs = client.EventService.Subscriptions
|
155
|
+
sub = subs.create({"Destination": "https://listener/events", "EventTypes": ["Alert"]})
|
156
|
+
```
|
157
|
+
|
158
|
+
### Firmware Updates
|
159
|
+
```python
|
160
|
+
client.UpdateService.SimpleUpdate(ImageURI="http://server/fw.bin", TransferProtocol="HTTP")
|
161
|
+
```
|
162
|
+
|
163
|
+
### System Health Monitoring
|
164
|
+
```python
|
165
|
+
for temp in chassis.Thermal.Temperatures:
|
166
|
+
print(f"{temp.Name}: {temp.ReadingCelsius}°C")
|
167
|
+
```
|
168
|
+
|
169
|
+
See [EXAMPLES.md](EXAMPLES.md) for 100+ more examples covering:
|
170
|
+
- BIOS configuration
|
171
|
+
- Certificate management
|
172
|
+
- Virtual media (KVM)
|
173
|
+
- LDAP authentication
|
174
|
+
- Boot order configuration
|
175
|
+
- SEL/log collection
|
176
|
+
- And much more...
|
177
|
+
|
178
|
+
## Testing
|
179
|
+
|
180
|
+
Run the test suite:
|
181
|
+
|
182
|
+
```bash
|
183
|
+
# Install with dev dependencies
|
184
|
+
pip install -e ".[dev]"
|
185
|
+
|
186
|
+
# Run all tests
|
187
|
+
pytest tests/
|
188
|
+
|
189
|
+
# Run with coverage
|
190
|
+
pytest --cov=rackfish tests/
|
191
|
+
|
192
|
+
# Run specific test file
|
193
|
+
pytest tests/test_common_usage.py
|
194
|
+
```
|
195
|
+
|
196
|
+
## Project Structure
|
197
|
+
|
198
|
+
```
|
199
|
+
rackfish/
|
200
|
+
├── rackfish/ # Main package
|
201
|
+
│ ├── __init__.py # Package initialization
|
202
|
+
│ └── client.py # Core library implementation
|
203
|
+
├── tests/ # Test suite
|
204
|
+
│ ├── __init__.py
|
205
|
+
│ ├── test_common_usage.py
|
206
|
+
│ ├── test_oem_links_surfacing.py
|
207
|
+
│ └── test_recursion_fix.py
|
208
|
+
├── examples/ # Usage examples
|
209
|
+
│ ├── examples_comprehensive.py
|
210
|
+
│ ├── demo_surfacing_comprehensive.py
|
211
|
+
│ └── example_oem_links.py
|
212
|
+
├── docs/ # Documentation
|
213
|
+
│ ├── INDEX.md
|
214
|
+
│ ├── EXAMPLES.md
|
215
|
+
│ ├── USE_CASES.md
|
216
|
+
│ ├── TESTS.md
|
217
|
+
│ ├── OEM_LINKS_SURFACING.md
|
218
|
+
│ └── COMPLETION_SUMMARY.md
|
219
|
+
├── .github/
|
220
|
+
│ └── copilot-instructions.md
|
221
|
+
├── README.md
|
222
|
+
├── CHANGELOG.md
|
223
|
+
├── CONTRIBUTING.md
|
224
|
+
├── LICENSE
|
225
|
+
├── pyproject.toml
|
226
|
+
└── requirements.txt
|
227
|
+
```
|
228
|
+
|
229
|
+
## Contributing
|
230
|
+
|
231
|
+
Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
232
|
+
|
233
|
+
## License
|
234
|
+
|
235
|
+
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
236
|
+
|
237
|
+
## Architecture
|
238
|
+
|
239
|
+
### Core Components
|
240
|
+
|
241
|
+
- **`RedfishClient`** - HTTP session management, authentication, base URL handling
|
242
|
+
- **`RedfishResource`** - Dynamic resource representation with lazy loading
|
243
|
+
- **`_convert`** - Recursive JSON-to-object mapping with link stub creation
|
244
|
+
- **`_hydrate`** - Property mapping, OEM/Links surfacing, action binding
|
245
|
+
|
246
|
+
### Key Design Patterns
|
247
|
+
|
248
|
+
1. **Lazy Loading** - Link stubs defer fetching until attribute access
|
249
|
+
2. **OEM Surfacing** - Vendor properties promoted to main object (collision-safe)
|
250
|
+
3. **Links Surfacing** - Related resources directly accessible (collision-safe)
|
251
|
+
4. **Action Methods** - Redfish Actions bound as callable instance methods
|
252
|
+
5. **ActionInfo Validation** - Parameter schemas fetched and enforced
|
253
|
+
|
254
|
+
### Recursion Guard
|
255
|
+
|
256
|
+
Deeply nested JSON structures are handled safely by deferring hydration (`fetched=False`) for all embedded resources, preventing stack overflow.
|
257
|
+
|
258
|
+
## Supported Use Cases
|
259
|
+
|
260
|
+
The library supports **150+ common Redfish operations** including:
|
261
|
+
|
262
|
+
### System Management
|
263
|
+
- Power control (Reset, ForceOff, GracefulRestart)
|
264
|
+
- Boot order configuration
|
265
|
+
- System health monitoring
|
266
|
+
|
267
|
+
### Storage
|
268
|
+
- Logical drive creation/deletion
|
269
|
+
- Storage controller management
|
270
|
+
- Drive inventory and status
|
271
|
+
|
272
|
+
### Network
|
273
|
+
- IP configuration (IPv4/IPv6)
|
274
|
+
- VLAN management
|
275
|
+
- DNS/NTP configuration
|
276
|
+
- SNMP trap configuration
|
277
|
+
|
278
|
+
### User & Security
|
279
|
+
- User account CRUD
|
280
|
+
- Role assignment
|
281
|
+
- Password policies
|
282
|
+
- LDAP integration
|
283
|
+
|
284
|
+
### Certificates
|
285
|
+
- CSR generation
|
286
|
+
- SSL/TLS certificate import
|
287
|
+
- SSH public key management
|
288
|
+
- Two-factor authentication certs
|
289
|
+
|
290
|
+
### Firmware & BIOS
|
291
|
+
- Firmware updates
|
292
|
+
- BIOS configuration
|
293
|
+
- BMC reset/rollback
|
294
|
+
|
295
|
+
### Monitoring & Logs
|
296
|
+
- Temperature sensors
|
297
|
+
- Fan speeds
|
298
|
+
- Voltage readings
|
299
|
+
- System event logs (SEL)
|
300
|
+
|
301
|
+
### Virtual Media
|
302
|
+
- ISO mounting (KVM)
|
303
|
+
- VNC/KVM configuration
|
304
|
+
- Virtual media operations
|
305
|
+
|
306
|
+
See [EXAMPLES.md](EXAMPLES.md) for complete list and code samples.
|
307
|
+
|
308
|
+
## OEM Vendor Support
|
309
|
+
|
310
|
+
Works with any Redfish-compliant BMC including:
|
311
|
+
|
312
|
+
- **Huawei** - TaiShan servers, FruControl, custom boot modes
|
313
|
+
- **Dell** - iDRAC, DellAttributes
|
314
|
+
- **HPE** - iLO, HPE-specific extensions
|
315
|
+
- **Lenovo** - XClarity
|
316
|
+
- **Supermicro** - IPMI/Redfish hybrid
|
317
|
+
- And any other vendor implementing Redfish standard
|
318
|
+
|
319
|
+
OEM extensions are automatically surfaced to the main object for easy access.
|
320
|
+
|
321
|
+
## Advanced Features
|
322
|
+
|
323
|
+
### Generic Request Helpers
|
324
|
+
|
325
|
+
For operations not exposed as methods:
|
326
|
+
|
327
|
+
```python
|
328
|
+
response = client.get("/redfish/v1/custom/path")
|
329
|
+
client.post("/redfish/v1/Actions/Custom", data={"param": "value"})
|
330
|
+
client.patch("/redfish/v1/Systems/1", data={"AssetTag": "NEW"})
|
331
|
+
client.delete("/redfish/v1/Collection/Item")
|
332
|
+
```
|
333
|
+
|
334
|
+
### Allowable Values
|
335
|
+
|
336
|
+
Get valid parameter values:
|
337
|
+
|
338
|
+
```python
|
339
|
+
reset_types = system.get_allowable_values("ResetType")
|
340
|
+
print(f"Valid reset types: {reset_types}")
|
341
|
+
```
|
342
|
+
|
343
|
+
### Raw JSON Access
|
344
|
+
|
345
|
+
```python
|
346
|
+
raw_data = resource.to_dict()
|
347
|
+
```
|
348
|
+
|
349
|
+
## Contributing
|
350
|
+
|
351
|
+
See `.github/copilot-instructions.md` for development guidelines and architecture details.
|
352
|
+
|
353
|
+
## License
|
354
|
+
|
355
|
+
See LICENSE file.
|
356
|
+
|
357
|
+
## Version
|
358
|
+
|
359
|
+
Current version: 1.0.0
|
360
|
+
|
361
|
+
## Requirements
|
362
|
+
|
363
|
+
- Python 3.8+
|
364
|
+
- requests library
|
365
|
+
|
366
|
+
## Support
|
367
|
+
|
368
|
+
For issues, questions, or contributions, please refer to the project repository.
|
@@ -0,0 +1,7 @@
|
|
1
|
+
rackfish/__init__.py,sha256=A_g1mxZrVYvpxU7J8jDqwbscTDzYD0a2E6IFsJg2kPA,866
|
2
|
+
rackfish/client.py,sha256=4eOyfQtqo9kbvzxiV2DqyeM6t4fgAnKbj3odpiS9dmY,23225
|
3
|
+
rackfish-1.0.0.dist-info/licenses/LICENSE,sha256=RqhA3IjOD-4eEnG-vVcLLU1vdpVCUyCQpytsCP3P0Q0,1071
|
4
|
+
rackfish-1.0.0.dist-info/METADATA,sha256=tuFKj_zm_YiPlC5ozyHbnW3L4P8PeuezIm9UEBqRJ00,11230
|
5
|
+
rackfish-1.0.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
6
|
+
rackfish-1.0.0.dist-info/top_level.txt,sha256=71o_wqNuMF4jtlGbXuI2-Epzd2a9oSizMmdcG3Sw_Mo,9
|
7
|
+
rackfish-1.0.0.dist-info/RECORD,,
|
@@ -0,0 +1,21 @@
|
|
1
|
+
MIT License
|
2
|
+
|
3
|
+
Copyright (c) 2025 Dmitrii Frolov
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
13
|
+
copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
21
|
+
SOFTWARE.
|
@@ -0,0 +1 @@
|
|
1
|
+
rackfish
|