Metadata-Version: 2.4
Name: pyredfish
Version: 0.2.0
Summary: A small Redfish client for server BMCs, with OEM CollectAllLog/DownloadAllLog support
Author-email: ilkermanap <ilkermanap@gmail.com>
License-Expression: GPL-3.0-or-later
Project-URL: Homepage, https://github.com/ilkermanap/pyredfish
Project-URL: Issues, https://github.com/ilkermanap/pyredfish/issues
Keywords: redfish,bmc,ipmi,idrac,ilo,openbmc,server,out-of-band
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: System Administrators
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: System :: Hardware
Classifier: Topic :: System :: Systems Administration
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: requests>=2.28
Dynamic: license-file

# pyredfish

A small, dependency-light Python client for the [Redfish](https://www.dmtf.org/standards/redfish)
server management API. Works against iDRAC, iLO, XClarity, OpenBMC and other
BMCs, and includes support for the vendor-specific `Oem/Public` **CollectAllLog**
and **DownloadAllLog** actions.

- `pyredfish/client.py` — the `RedfishClient` class (the whole library)
- `pyredfish/cli.py` — the `pyredfish` command line tool
- `examples/` — runnable example scripts

Requires Python 3.10+ and `requests`.

## Install

```bash
pip install pyredfish
```

From a checkout:

```bash
python3 -m venv venv && source venv/bin/activate
pip install -e .
```

Installing puts a `pyredfish` command on your PATH; `python -m pyredfish` works
too.

## Quick start

```python
from pyredfish import RedfishClient

with RedfishClient("https://10.0.0.5", "admin", "secret", verify=False) as rf:
    print(rf.system_info())
    print(rf.power_state())        # "On" / "Off"
    rf.power_on()
```

Or from the shell:

```bash
pyredfish -H 10.0.0.5 -u admin -p secret -k info
```

---

## Connecting

```python
RedfishClient(
    base_url=None,        # "https://10.0.0.5", or "10.0.0.5" (https:// is added)
    username=None,
    password=None,
    *,
    verify=True,          # True | False | "/path/ca.pem"
    timeout=30.0,         # seconds, per request
    use_session=True,     # False -> HTTP Basic Auth on every request
)
```

If `base_url`, `username` or `password` are omitted they are read from the
`REDFISH_URL`, `REDFISH_USER` and `REDFISH_PASSWORD` environment variables:

```python
import os
os.environ["REDFISH_URL"] = "10.0.0.5"
os.environ["REDFISH_USER"] = "admin"
os.environ["REDFISH_PASSWORD"] = "secret"

with RedfishClient(verify=False) as rf:
    print(rf.power_state())
```

### TLS certificates

BMCs ship with self-signed certificates, so `verify=False` is the common case
(urllib3's warning is silenced automatically). If you deployed your own CA,
point at it instead and keep verification on:

```python
RedfishClient("https://bmc.example.com", "admin", "secret", verify="/etc/ssl/bmc-ca.pem")
```

### Sessions and tokens

Session handling lives entirely inside the class:

- **No explicit login needed.** The first request opens a Redfish session
  (`POST /redfish/v1/SessionService/Sessions`) and stores the `X-Auth-Token`.
- **Automatic re-login.** If the token expires and the BMC answers `401`, the
  client logs in again and retries the request **once**. Bad credentials fail
  immediately instead of looping.
- **Clean logout.** Leaving the `with` block (or calling `logout()`) deletes the
  session on the BMC. This matters: most BMCs allow only a handful of
  concurrent sessions and leaked ones lock you out until they time out.
- After `logout()` the client refuses further calls rather than silently
  opening a new session. Call `login()` to reuse it.

```python
rf = RedfishClient("10.0.0.5", "admin", "secret", verify=False)
print(rf.power_state())    # logs in on demand
rf.logout()                # session deleted on the BMC

rf.login()                 # explicit re-open
print(rf.power_state())
rf.logout()
```

Use `use_session=False` for BMCs whose SessionService is broken or disabled;
every request then carries HTTP Basic Auth instead.

---

## Usage examples

### Inventory report

```python
with RedfishClient("10.0.0.5", "admin", "secret", verify=False) as rf:
    info = rf.system_info()
    print(f"{info['Manufacturer']} {info['Model']} (SN {info['SerialNumber']})")
    print(f"BIOS {info['BiosVersion']}, health {info['Health']}")
    print(f"{info['ProcessorCount']} x {info['ProcessorModel']}, "
          f"{info['MemoryGiB']} GiB RAM")

    for cpu in rf.processors():
        print(cpu["Id"], cpu.get("Model"), cpu.get("TotalCores"), "cores")

    for dimm in rf.memory():
        if dimm.get("CapacityMiB"):
            print(dimm["Id"], dimm["CapacityMiB"], "MiB",
                  dimm.get("Manufacturer"), dimm.get("PartNumber"))

    for nic in rf.ethernet_interfaces():
        print(nic["Id"], nic.get("MACAddress"), nic.get("IPv4Addresses"))

    for ctrl in rf.storage():
        for drive in ctrl.get("Drives", []):
            print("drive:", drive["@odata.id"])
```

### Power control

```python
with RedfishClient("10.0.0.5", "admin", "secret", verify=False) as rf:
    if rf.power_state() == "Off":
        rf.power_on()

    rf.power_off()                 # GracefulShutdown — asks the OS
    rf.power_off(force=True)       # ForceOff — pulls the plug
    rf.restart()                   # GracefulRestart
    rf.restart(force=True)         # ForceRestart

    rf.reset("PowerCycle")         # any ResetType the BMC advertises
```

`reset()` checks the BMC's `ResetType@Redfish.AllowableValues` first and raises
`RedfishError` for an unsupported type instead of sending a request that would
fail. `RedfishClient.RESET_TYPES` lists the standard values.

### Waiting for a power state

```python
import time

def wait_for_power(rf, wanted, timeout=300, interval=5):
    deadline = time.monotonic() + timeout
    while rf.power_state() != wanted:
        if time.monotonic() > deadline:
            raise TimeoutError(f"still {rf.power_state()}, wanted {wanted}")
        time.sleep(interval)

with RedfishClient("10.0.0.5", "admin", "secret", verify=False) as rf:
    rf.power_off(force=True)
    wait_for_power(rf, "Off")
    rf.power_on()
    wait_for_power(rf, "On")
```

### One-shot PXE boot (reprovisioning)

```python
with RedfishClient("10.0.0.5", "admin", "secret", verify=False) as rf:
    print("supported targets:", rf.boot_options())

    rf.set_boot_override("Pxe", uefi=True)   # next boot only
    rf.restart(force=True)
```

`persistent=True` keeps the override for every boot
(`BootSourceOverrideEnabled = "Continuous"`); omit `uefi` to leave the boot mode
alone, or pass `uefi=False` for legacy BIOS mode. Other common targets are
`Hdd`, `Cd`, `Usb`, `BiosSetup` and `Utilities`.

### Mounting an ISO over virtual media

```python
with RedfishClient("10.0.0.5", "admin", "secret", verify=False) as rf:
    for device in rf.virtual_media():
        print(device["Id"], device.get("MediaTypes"), "inserted:",
              device.get("Inserted"))

    rf.insert_virtual_media("http://10.0.0.9/images/rescue.iso")
    rf.set_boot_override("Cd", uefi=True)
    rf.restart(force=True)

    # ... after the install ...
    rf.eject_virtual_media()
```

The CD device is found from the vendor profile and, failing that, from each
device's `MediaTypes` — so the same call works on a BMC that calls it `CD` and
on iLO, where it is device `2`. Pass `media_id=` to force one, and
`manager_index=` when the machine has more than one BMC.

### Thermal and power draw

```python
with RedfishClient("10.0.0.5", "admin", "secret", verify=False) as rf:
    thermal = rf.thermal()
    for sensor in thermal.get("Temperatures", []):
        print(f"{sensor.get('Name'):<28} {sensor.get('ReadingCelsius')} C "
              f"(upper critical {sensor.get('UpperThresholdCritical')})")
    for fan in thermal.get("Fans", []):
        print(fan.get("Name"), fan.get("Reading"), fan.get("ReadingUnits"))

    for ctrl in rf.power().get("PowerControl", []):
        print("draw:", ctrl.get("PowerConsumedWatts"), "W",
              "| average:", ctrl.get("PowerMetrics", {}).get("AverageConsumedWatts"))
    for psu in rf.power().get("PowerSupplies", []):
        print(psu.get("Name"), psu.get("Status", {}).get("Health"),
              psu.get("LastPowerOutputWatts"), "W")
```

### Firmware inventory

```python
with RedfishClient("10.0.0.5", "admin", "secret", verify=False) as rf:
    for item in rf.firmware_inventory():
        print(f"{item.get('Name'):<40} {item.get('Version')}")
```

### Event log (SEL)

```python
with RedfishClient("10.0.0.5", "admin", "secret", verify=False) as rf:
    print("log services:", rf.log_service_ids())     # ["SEL"] / ["IML", "SL"]

    for entry in rf.log_entries(limit=20):           # vendor-aware default
        print(entry.get("Created"), entry.get("Severity"), entry.get("Message"))

    for entry in rf.log_entries(scope="manager", limit=20):
        print("BMC:", entry.get("Message"))
```

With no `log_id`, `log_entries()` tries the ids in the active vendor profile
(`SEL` on most BMCs, `IML`/`SL` on HPE, `Sel`/`Lclog` on Dell) and returns the
first log it finds. Pass an explicit id to force one, or `limit=None` for every
entry.

`scope` selects where to look: `"system"`, `"manager"`, or the default
`"auto"` — system first, then the BMC, which is what makes Dell work without
special-casing.

Filter without post-processing:

```python
rf.log_entries(severity="Warning")               # Warning and Critical
rf.log_entries(since="2026-09-01T00:00:00Z")     # ISO 8601 or a datetime
rf.log_entries(severity="Critical", since=yesterday, limit=None)
```

An entry whose timestamp cannot be parsed is kept rather than dropped — hiding
a real event is worse than showing an extra one.

---

## Alarms

Event logs are history. The *current* alarm state lives in the `Status.Health`
of each resource and in the thermal thresholds, so it is read separately:

```python
thermal = rf.thermal()
for sensor in thermal.get("Temperatures", []):
    limit = sensor.get("UpperThresholdCritical")
    if limit and sensor.get("ReadingCelsius", 0) >= limit:
        print("ALARM:", sensor["Name"], sensor["ReadingCelsius"])

for psu in rf.power().get("PowerSupplies", []):
    if psu.get("Status", {}).get("Health") not in (None, "OK"):
        print("ALARM:", psu["Name"], psu["Status"]["Health"])
```

The same check across system health, sensors, fans, PSUs, DIMMs, CPUs and
critical log entries is built into the CLI:

```bash
pyredfish -H 10.0.0.5 -u admin -k alarms      # exit code 1 if anything is wrong
```

The non-zero exit on alarms makes it usable straight from cron or a monitoring
check. `examples/health_monitor.py` polls the same data on a loop.

### Push notifications (EventService)

Polling is fine for a handful of machines; beyond that, subscribe and let the
BMC POST events to you:

```python
print(rf.event_service()["EventTypesForSubscription"])   # what it can send

uri = rf.subscribe("https://nms.example.com:8443/redfish-events",
                   event_types=["Alert"], context="dc1-rack3")

for sub in rf.subscriptions():
    print(sub["Id"], sub["Destination"], sub.get("Context"))

rf.unsubscribe(uri)      # full URI or just the id
```

The destination must be an HTTPS endpoint the BMC can reach; `Context` comes
back with every event, so use it to identify the machine. `EventTypes` is only
sent when the BMC advertises it — it was deprecated in Redfish 1.6 and newer
firmware rejects it.

```bash
pyredfish -H 10.0.0.5 -u admin -k subscriptions
pyredfish -H 10.0.0.5 -u admin -k subscribe https://nms:8443/events --context dc1
pyredfish -H 10.0.0.5 -u admin -k unsubscribe 3
```

## Collecting all logs (OEM)

Many BMCs expose a bundle-everything action outside the Redfish standard:

```
POST /redfish/v1/Managers/1/LogServices/Actions/Oem/Public/CollectAllLog
POST /redfish/v1/Managers/1/LogServices/Actions/Oem/Public/DownloadAllLog
```

The client discovers those targets from the `Actions.Oem` block of the
LogServices document and falls back to the paths above when the BMC does not
advertise them, so it keeps working across firmware revisions.

```python
with RedfishClient("10.0.0.5", "admin", "secret", verify=False) as rf:
    # collect + download in one call; the file name comes from
    # the server's Content-Disposition header
    path = rf.download_all_log("./logs/")
    print("saved to", path)
```

Split into two steps when you want control over the wait:

```python
with RedfishClient("10.0.0.5", "admin", "secret", verify=False) as rf:
    task = rf.collect_all_log(timeout=1200)      # blocks until the BMC is done
    print("task state:", task.get("TaskState") if task else "no task returned")

    rf.download_all_log("./logs/bmc-10.0.0.5.tar.gz", collect_first=False)
```

Notes:

- `collect_all_log()` returns a Task if the BMC answers `202 Accepted` with a
  `Location` header, and polls it until it finishes (`timeout=600`,
  `interval=5` by default). A failed task raises `RedfishError` carrying the
  BMC's own message.
- The download is **streamed** in 1 MiB chunks, so a multi-hundred-megabyte
  bundle never has to fit in memory.
- If `dest` is a directory (or ends with a path separator) the file name is
  taken from `Content-Disposition`, otherwise `dest` is used verbatim and its
  parent directories are created.
- Firmware differs on the HTTP verb. `method="auto"` (the default) tries POST
  and falls back to GET on `400/404/405/501`; force it with `method="POST"` or
  `method="GET"`.
- Some firmware wants a body (`{"Type": "all"}` and similar). Pass it through:
  `rf.collect_all_log(payload={"Type": "all"})`.

### Bundling logs from many machines

```python
import concurrent.futures, pathlib
from pyredfish import RedfishClient, RedfishError

HOSTS = ["10.0.0.5", "10.0.0.6", "10.0.0.7"]
OUT = pathlib.Path("./logs")
OUT.mkdir(exist_ok=True)

def grab(host):
    try:
        with RedfishClient(host, "admin", "secret", verify=False) as rf:
            target = OUT / host
            target.mkdir(exist_ok=True)
            return host, rf.download_all_log(str(target) + "/")
    except RedfishError as exc:
        return host, f"FAILED: {exc}"

with concurrent.futures.ThreadPoolExecutor(max_workers=8) as pool:
    for host, result in pool.map(grab, HOSTS):
        print(f"{host}: {result}")
```

Each thread gets its own client and therefore its own BMC session — do not
share one `RedfishClient` across threads.

---

---

## Vendor differences

Redfish is a standard, but every BMC bends it somewhere. pyredfish handles that
in three layers, in this order:

1. **Discovery.** Action targets, allowed reset types, log service ids and
   virtual media devices are read from the BMC's own documents. Most
   differences never reach the other two layers.
2. **Vendor profile.** What discovery cannot answer is expressed as data — id
   candidates, OEM namespaces, HTTP verbs, reset preferences.
3. **Profile hooks.** Only when a vendor does something structurally different
   (HPE's AHS dump) does the profile override behaviour with code.

An unrecognised BMC uses the `generic` profile and behaves exactly as before,
so a new machine never fails just because it is unknown.

```python
with RedfishClient("10.0.0.5", "admin", "secret", verify=False) as rf:
    print(rf.profile.name)          # "hpe", "kaytus" or "generic"
```

Detection reads `ServiceRoot.Vendor`, the `Oem` keys and `Manager.Manufacturer`
— never the hostname or IP. Pin it when detection guesses wrong:

```python
RedfishClient("10.0.0.5", "admin", "secret", verify=False, vendor="hpe")
```

```bash
pyredfish -H 10.0.0.5 -u admin -k --vendor hpe info
```

### Supported profiles

| Profile | Detected by | What it changes |
| --- | --- | --- |
| `generic` | fallback | Standard Redfish, OEM action then `CollectDiagnosticData` |
| `dell` | `Oem.Dell`, iDRAC vendor string | `Sel`/`Lclog` under the BMC, `CD`/`RemovableDisk` media, SupportAssist collection |
| `hpe` | `Oem.Hpe` / `Oem.Hp` | `IML`/`SL`/`IEL` logs, numbered virtual media, AHS download |
| `kaytus` | `Oem.Public`, KAYTUS/Inspur | `Oem/Public` action namespace, log id order |
| `lenovo` | `Oem.Lenovo`, XClarity/ThinkSystem | `DiagnosticLog` FFDC collection, `StandardLog` events |
| `openbmc` | `Oem.OpenBMC` | `EventLog`, `Dump` service, `Slot_0` virtual media |
| `supermicro` | `Oem.Supermicro` | `Log1` events, `Dump` service with `OEM`/`AllLog` |

**HPE (iLO 4/5/6)** — verified against HPE's Redfish documentation:

- Event logs are not `SEL`: the system carries `IML` (Integrated Management
  Log) and `SL` (Security Log), the BMC carries `IEL` (iLO Event Log).
  `rf.log_entries()` picks the right one; `rf.log_entries(scope="manager")`
  reads the iLO log.
- Virtual media devices are numbered, not named: `1` is the virtual
  floppy/USB, `2` is the virtual CD/DVD. `rf.insert_virtual_media(url)` finds
  the CD device without being told.
- "Collect all logs" is the **Active Health System** dump, which is not a
  Redfish action but a query on the AHS resource. `download_all_log()` maps
  onto it, so the same call works on iLO and on everything else:

```python
rf.download_all_log("./logs/")                       # whole AHS record
rf.download_all_log("./logs/", days=7)               # last 7 days
rf.download_all_log("./logs/", date_from="2026-08-01", date_to="2026-08-31")
```

**KAYTUS (and the Inspur BMCs it descends from)** — the toggle is the
`Oem/Public` namespace, where the log bundle lives on the LogServices
*collection*:

```
POST /redfish/v1/Managers/1/LogServices/Actions/Oem/Public/CollectAllLog
POST /redfish/v1/Managers/1/LogServices/Actions/Oem/Public/DownloadAllLog
```

`collect_all_log()` / `download_all_log()` use these, preferring the targets
the BMC advertises in `Actions.Oem` and falling back to the paths above.

**Lenovo (XCC), Supermicro and OpenBMC** use the DMTF-standard
`LogService.CollectDiagnosticData` instead of an OEM action, so
`download_all_log()` runs the action, waits for the task and downloads the
resulting entry's `AdditionalDataURI`. The differences are only *which* log
service hosts it and which parameters it wants — Lenovo collects
`DiagnosticDataType: "Manager"` from `DiagnosticLog`, Supermicro collects
`"OEM"` + `OEMDiagnosticDataType: "AllLog"` from `Dump`. You can call it
directly:

```python
entry = rf.collect_diagnostic_data()          # waits for the task
path = rf.download_diagnostic_data("./logs/") # collect + download
```

**Dell (iDRAC)** is the one vendor where the bundle cannot be pulled straight
over Redfish. `collect_all_log()` starts a SupportAssist collection (a Tech
Support Report) and waits for the job, but iDRAC writes the file to a share
rather than serving it back, so `download_all_log()` raises a `RedfishError`
explaining what to do instead:

```python
rf.collect_all_log(share={"ShareType": "NFS", "IPAddress": "10.0.0.9",
                          "ShareName": "/export/tsr"})
```

Dell also keeps its event logs under the BMC (`Sel`, `Lclog`) rather than the
system — `log_entries()` finds them anyway, because the default
`scope="auto"` falls back from the system to the manager.

### Reporting a new BMC

`pyredfish probe` prints exactly the structure a profile is written from —
vendor strings, OEM keys, reset types, log service ids, virtual media ids and
the OEM action block. Serial numbers, UUIDs, MACs, IPs and host names are
redacted, so the output is safe to paste into an issue:

```bash
pyredfish -H 10.0.0.5 -u admin -k probe > my-bmc.json
```

### Adding a profile

Subclass `VendorProfile`, set the fields that differ, and score `matches()`
against the service root:

```python
from pyredfish.vendors import VendorProfile, register

@register
class AcmeProfile(VendorProfile):
    name = "acme"
    system_log_ids = ("EventLog", "SEL")
    virtual_media_ids = ("Cd1",)
    oem_namespaces = ("Acme",)

    @classmethod
    def matches(cls, root, manager):
        return 10 if "Acme" in cls._oem_keys(root, manager) else 0
```

Every field, with its `generic` default:

| Field | Default | Meaning |
| --- | --- | --- |
| `name` | `"generic"` | Value accepted by `--vendor` |
| `system_log_ids` | `("SEL", "EventLog", "Log")` | Ordered candidates for the system event log |
| `manager_log_ids` | `("Log", "EventLog", "SEL")` | Same, for the BMC's own log |
| `virtual_media_ids` | `("CD", "CD1", "1")` | Ordered candidates for the CD/DVD device |
| `oem_namespaces` | `("Public",)` | OEM action namespace (`#Public.CollectAllLog`) |
| `download_verbs` | `("POST", "GET")` | Methods tried for the OEM download action |
| `collect_payload` | `{}` | Extra body for the OEM collect action |
| `diagnostic_log_ids` | `("Dump", "DiagnosticLog", "Diagnostic")` | Log services to prefer for `CollectDiagnosticData` |
| `diagnostic_data_type` | `"Manager"` | `DiagnosticDataType` sent with it |
| `oem_diagnostic_data_type` | `None` | `OEMDiagnosticDataType`, when the type is `OEM` |
| `patch_if_match` | `"*"` | Default `If-Match` header; `None` sends none |
| `graceful_off_types` | `("GracefulShutdown",)` | Preference order for `power_off()` |
| `force_off_types` | `("ForceOff",)` | Preference order for `power_off(force=True)` |
| `graceful_restart_types` | `("GracefulRestart",)` | Preference order for `restart()` |
| `force_restart_types` | `("ForceRestart", "PowerCycle")` | Preference order for `restart(force=True)` |

Only put equivalent values in the power lists — a graceful list must never
contain a forceful type, or a polite shutdown silently becomes a power cut.
The client picks the first entry the BMC advertises in
`ResetType@Redfish.AllowableValues` and raises if none of them fit.

Two optional hooks, `collect_all_log(client, **kw)` and
`download_all_log(client, dest, **kw)`, override behaviour when data is not
enough — that is how HPE's AHS and Dell's SupportAssist are implemented.

Third-party packages can ship profiles without touching this repository by
declaring a `pyredfish.vendors` entry point:

```toml
[project.entry-points."pyredfish.vendors"]
acme = "pyredfish_acme:AcmeProfile"
```


## Raw access

Every Redfish resource is reachable even when there is no helper for it:

```python
with RedfishClient("10.0.0.5", "admin", "secret", verify=False) as rf:
    bios = rf.get("/redfish/v1/Systems/1/Bios")
    print(bios["Attributes"]["BootMode"])

    # staged BIOS change, applied on next reboot
    rf.patch("/redfish/v1/Systems/1/Bios/Settings",
             {"Attributes": {"BootMode": "Uefi", "ProcTurboMode": "Enabled"}})

    # any action
    rf.post("/redfish/v1/Managers/1/Actions/Manager.Reset",
            {"ResetType": "GracefulRestart"})

    # walk a collection
    for account in rf.members("/redfish/v1/AccountService/Accounts"):
        print(account.get("Id"), account.get("UserName"), account.get("RoleId"))

    rf.delete("/redfish/v1/SessionService/Sessions/12")
```

`patch()` sends `If-Match: *` by default; override it with
`headers={"If-Match": etag}` when a BMC insists on a real ETag.

### Long-running tasks

Actions that return `202 Accepted` can be awaited with `wait_for_task()`:

```python
resp = rf.post("/redfish/v1/UpdateService/Actions/UpdateService.SimpleUpdate",
               {"ImageURI": "http://10.0.0.9/fw/bios.bin",
                "TransferProtocol": "HTTP"})
task = rf.wait_for_task("/redfish/v1/TaskService/Tasks/3",
                        timeout=1800, interval=10)
print(task["TaskState"])
```

## Error handling

```python
from pyredfish import RedfishClient, RedfishError, RedfishAuthError

try:
    with RedfishClient("10.0.0.5", "admin", "wrong", verify=False) as rf:
        rf.power_on()
except RedfishAuthError as exc:
    print("check the credentials:", exc)
except RedfishError as exc:
    print("request failed:", exc)
    print("status:", exc.status_code)
    print("body:", exc.body)          # the BMC's parsed JSON error
```

`RedfishError.status_code` and `.body` carry the BMC's own response, and the
message is built from Redfish's `@Message.ExtendedInfo` block so it reads like
the vendor's own wording. `RedfishAuthError` is a subclass of `RedfishError`,
raised on `401`/`403`. Network-level problems surface as
`requests.exceptions.*` (`ConnectionError`, `Timeout`) unchanged.

---

## Command line

```bash
pyredfish -H 10.0.0.5 -u admin -p secret -k info
pyredfish -H 10.0.0.5 -u admin -k status              # prompts for the password
pyredfish -H 10.0.0.5 -u admin -k off --force
pyredfish -H 10.0.0.5 -u admin -k boot Pxe --uefi
pyredfish -H 10.0.0.5 -u admin -k restart -f
pyredfish -H 10.0.0.5 -u admin -k sel -n 50
pyredfish -H 10.0.0.5 -u admin -k sel --severity Warning --since 2026-09-01
pyredfish -H 10.0.0.5 -u admin -k alarms
pyredfish -H 10.0.0.5 -u admin -k dump ./logs/
pyredfish -H 10.0.0.5 -u admin -k download-log ./logs/
pyredfish -H 10.0.0.5 -u admin -k -j get /redfish/v1/Systems/1
```

Global options:

| Option | Meaning |
| --- | --- |
| `-H`, `--host`, `--url` | BMC address or IP (env `REDFISH_URL`) |
| `-u`, `--user` | user name (env `REDFISH_USER`) |
| `-p`, `--password` | password (env `REDFISH_PASSWORD`); prompted if omitted |
| `-k`, `--insecure` | skip TLS verification |
| `--ca FILE` | verify against your own CA |
| `--basic-auth` | use HTTP Basic Auth instead of a session |
| `--timeout SEC` | per-request timeout (default 30) |
| `-j`, `--json` | print raw JSON |
| `-V`, `--version` | print the version and exit |
| `--vendor NAME` | pin the vendor profile instead of detecting it |

Commands: `info`, `status`, `on`, `off`, `restart`, `boot`, `boot-options`,
`nics`, `power-usage`, `firmware`, `sel`, `alarms`, `probe`, `dump`,
`collect-log`, `download-log`, `subscriptions`, `subscribe`, `unsubscribe`,
`bmc-reset`, `get`. Run `pyredfish --help` or `pyredfish <command> --help` for
details. Exit codes: `0` success, `1` Redfish error, `2` bad arguments,
`130` interrupted.

Credentials on the command line are visible in `ps` output — prefer the
environment variables or the password prompt on shared machines:

```bash
export REDFISH_URL=10.0.0.5 REDFISH_USER=admin REDFISH_PASSWORD=secret
pyredfish -k info
```

---

## API reference

### `RedfishClient`

| Area | Methods |
| --- | --- |
| Session | `login()`, `logout()`, context manager |
| Raw HTTP | `get(path)`, `post(path, payload)`, `patch(path, payload)`, `delete(path)` |
| Navigation | `service_root()`, `systems()`, `chassis()`, `managers()`, `system_uri(i)`, `manager_uri(i)`, `system(i)`, `members(uri)`, `member_uris(uri)` |
| Power | `power_state()`, `power_on()`, `power_off(force=)`, `restart(force=)`, `reset(type)` |
| Boot | `boot_options()`, `set_boot_override(target, persistent=, uefi=)` |
| Inventory | `system_info()`, `processors()`, `memory()`, `ethernet_interfaces()`, `storage()`, `firmware_inventory()` |
| Chassis | `thermal(i)`, `power(i)` |
| Logs | `log_entries(log_id, limit=, scope=, severity=, since=)`, `log_service_ids(scope=)`, `log_services()`, `log_services_uri(i)` |
| Log bundles | `collect_all_log(...)`, `download_all_log(dest, ...)`, `collect_diagnostic_data(...)`, `download_diagnostic_data(dest, ...)` |
| Events | `event_service()`, `subscriptions()`, `subscribe(destination, ...)`, `unsubscribe(id)` |
| Tasks | `wait_for_task(uri, timeout=, interval=)` |
| Virtual media | `virtual_media(i)`, `insert_virtual_media(url, ...)`, `eject_virtual_media(...)` |
| BMC | `reset_bmc(reset_type=)` |
| Vendor | `profile`, `detect_vendor()`, `log_service_ids(scope=)` |

Every method that touches a system or chassis takes an `index` (or
`manager_index` / `chassis_index`) argument, defaulting to `0` — the first
resource in the collection. Multi-node chassis are addressed with
`rf.power_state(index=1)` and friends.

`system_info()` returns a flat dict with the keys `Id`, `Manufacturer`,
`Model`, `SerialNumber`, `SKU`, `UUID`, `BiosVersion`, `PowerState`, `Health`,
`State`, `ProcessorCount`, `ProcessorModel`, `MemoryGiB` and `HostName`;
missing fields come back as `None` rather than raising.

## Examples directory

| File | What it shows |
| --- | --- |
| `examples/inventory.py` | full hardware report for one host |
| `examples/power_cycle.py` | power state machine with waiting |
| `examples/pxe_reinstall.py` | one-shot PXE boot for reprovisioning |
| `examples/virtual_media_install.py` | mount an ISO, boot it, eject |
| `examples/collect_logs.py` | OEM CollectAllLog / DownloadAllLog |
| `examples/fleet_report.py` | CSV inventory across many BMCs, in parallel |
| `examples/health_monitor.py` | poll temperature, fans, power and SEL |
| `examples/raw_bios.py` | raw GET/PATCH against BIOS attributes |

Each script takes the BMC as its first argument, or falls back to
`REDFISH_URL`; the credentials come from `REDFISH_USER` / `REDFISH_PASSWORD`
and are prompted for when unset. They can be run from anywhere.

```bash
export REDFISH_USER=admin REDFISH_PASSWORD=secret

python3 examples/inventory.py 10.0.0.5
python3 examples/collect_logs.py 10.0.0.5 ./logs/
python3 examples/virtual_media_install.py 10.0.0.5 http://10.0.0.9/rescue.iso
python3 examples/raw_bios.py 10.0.0.5 BootMode Uefi
python3 examples/fleet_report.py hosts.txt > fleet.csv   # one host per line
```

`examples/_common.py` holds the shared connect-from-environment helper the
other scripts import.

## Compatibility notes

- Redfish is a standard, but coverage varies. Anything under `Oem` — including
  `CollectAllLog` / `DownloadAllLog` — is vendor-specific and may not exist on
  your BMC.
- The client always checks what the BMC advertises before sending an action
  (reset types, action targets, log service ids) and raises a clear
  `RedfishError` instead of failing obscurely.
- BMC session limits are low (often 4–8). Always use the context manager or
  call `logout()` so sessions are not leaked.

## License

GNU General Public License v3.0 or later — see `LICENSE`.

## Releasing to PyPI

The project is packaged with `pyproject.toml` (setuptools backend); the version
lives in `pyredfish/__init__.py` and everything else is derived from it.

```bash
pip install build twine

rm -rf dist/
python -m build          # builds dist/*.whl and dist/*.tar.gz
twine check dist/*       # validates the metadata PyPI will render

twine upload --repository testpypi dist/*   # rehearsal on TestPyPI
twine upload dist/*                         # the real thing
```

Publishing needs a PyPI account with 2FA enabled and an **API token** (PyPI no
longer accepts passwords for uploads). Put it in `~/.pypirc`, or export it:

```bash
export TWINE_USERNAME=__token__
export TWINE_PASSWORD=pypi-AgEIcHlwaS5vcmc...
```

A released version number can never be reused, so bump `__version__` before
every upload.
