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 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
+ [![PyPI version](https://img.shields.io/pypi/v/rackfish.svg)](https://pypi.org/project/rackfish/)
42
+ [![Python Versions](https://img.shields.io/pypi/pyversions/rackfish.svg)](https://pypi.org/project/rackfish/)
43
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
44
+ [![Build Status](https://github.com/yourusername/rackfish/workflows/CI/badge.svg)](https://github.com/yourusername/rackfish/actions)
45
+ [![codecov](https://codecov.io/gh/yourusername/rackfish/branch/main/graph/badge.svg)](https://codecov.io/gh/yourusername/rackfish)
46
+ [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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