Migrating from 0.2.0 to dataclasses¶
Upgrading to 0.3.0
These changes were released in 0.3.0. Version 0.2.0 uses Pydantic. See installation to upgrade the package.
The configuration and result models now use standard-library dataclasses.
Client, ClientConfig, Protocol, Result, and Server retain their
existing entry points. Pydantic's model methods, coercion, and validation
exceptions are no longer part of those objects.
CFFI is the sole direct Python runtime dependency. Its own dependencies and the native libiperf requirement remain. This change does not expand the tested platform matrix or claim faster benchmark execution.
Replace model operations deliberately¶
| 0.2.0 operation | 0.3.0 replacement |
|---|---|
ClientConfig.model_validate(mapping) |
ClientConfig(**mapping) after application input parsing. |
ClientConfig.model_validate_json(text) |
ClientConfig(**json.loads(text)) for a configuration JSON object. |
config.model_dump() |
dataclasses.asdict(config) for a detached Python mapping. |
config.model_copy(update={...}) |
dataclasses.replace(config, ...); construction validates the new values. |
result.model_dump() |
result.to_dict() for an unversioned application snapshot. |
result.model_dump_json() for archives |
dumps_artifact(artifact_from_result(result)); this intentionally uses a new versioned format. |
Result.model_validate(...) |
Choose the parser for the input format below; there is no generic recursive dataclass replacement. |
Catch pydantic.ValidationError for configuration |
Catch TypeError or ValueError. |
| Pydantic schema/introspection helpers and dump options | No equivalent model framework is supplied; keep application-owned adapters where needed. |
dataclasses.asdict() does not validate an object or promise JSON scalar
conversion. For configuration JSON, convert an IP address object explicitly:
import json
from dataclasses import asdict, replace
from iperf3_lib import ClientConfig, Protocol
config = ClientConfig(server="127.0.0.1", protocol=Protocol.UDP, duration=3)
reverse = replace(config, reverse=True)
config_data = asdict(reverse)
config_data["server"] = str(reverse.server)
config_data["protocol"] = reverse.protocol.value
config_json = json.dumps(config_data, allow_nan=False)
restored_config = ClientConfig(**json.loads(config_json))
The result, statistics, and configuration dataclasses remain mutable. A
dataclass constructor does not recursively turn nested dictionaries into
models. In particular, Result(**saved_snapshot) can leave dictionaries
where statistics objects are expected; it is not a snapshot decoder.
Configuration is stricter at the boundary¶
ClientConfig checks types, ranges, and option combinations explicitly:
- Integer fields require integers. Numeric strings, floats, and booleans are rejected, even when a conversion might appear lossless.
- Boolean options require
TrueorFalse, not"true","false",0, or1. - Exact protocol strings
"tcp","udp", and"sctp"normalize toProtocol. - Hostname/address strings and standard-library IP address objects remain
accepted. An unknown constructor option raises
TypeError. - Bounds, conflicting reverse/bidirectional modes, and UDP block-size rules
remain enforced. Invalid types raise
TypeError; invalid values or combinations raiseValueError.
Parse environment variables, command-line strings, or application forms into
the intended types before constructing a configuration. Avoid generic
bool(text) conversion: a nonempty "false" string would become true.
Direct attribute assignment does not rerun validation. Prefer a fresh instance
or replace(config, ...). Each Client.run() revalidates and executes a
detached configuration snapshot, so later caller mutations cannot alter that
admitted run. The result retains the admitted request separately from native
settings verified through returned evidence.
The low-level ClientConfig.rate remains bits/s per stream. Higher-level
RateIntent, unit parsing, and admission estimates
are separate APIs. Native feature rejection still raises
UnsupportedFeatureError when the installed native build cannot apply a request.
The expanded native controls add worker-backed MPTCP and
streaming; use a reviewed revision containing that implementation.
See the configuration reference for exact
bounds and protocol defaults.
Identify the data format before loading it¶
The published 0.2.0 result models
contained ok, error, raw, and end. Normalized flows, timing metadata,
to_dict(), and the artifact API were added during development after that
release. These formats have different contracts:
| Input | Meaning | Loading path |
|---|---|---|
Native iperf JSON, usually containing start/intervals/end or error |
Native measurements and metadata | result_from_iperf_json(json.loads(text)) |
0.2.0 Pydantic Result.model_dump() / model_dump_json() |
A wrapper snapshot containing native JSON under raw |
Explicit migration with the original status retained; see below. |
The documented historical development Result.to_dict() snapshot |
An unversioned normalized snapshot | artifact_from_legacy_dict() for that specific legacy field set. |
A versioned envelope with kind="iperf3-lib.result" and schema_version=1 |
Durable normalized result plus producer identity | loads_artifact() or artifact_from_dict() |
Do not detect a format by filename alone. A Pydantic dump or artifact is not a native iperf document. The native parser does not decode those envelopes. Similarly, an artifact reader does not silently guess how to upgrade an unversioned dictionary.
Native JSON¶
import json
from pathlib import Path
from iperf3_lib.result import result_from_iperf_json
native = json.loads(Path("iperf-native.json").read_text(encoding="utf-8"))
result = result_from_iperf_json(native, reporting_role="client")
Pass reporting_role only when the producer is known. Otherwise omit it and
retain any resulting unknown direction/observer diagnostics. Re-normalizing
native JSON intentionally applies the current parser's interpretation. Loading
a versioned artifact instead preserves its writer's normalized record.
Published 0.2.0 Pydantic dumps¶
Keep the original archive. The explicit example below accepts a full,
unmodified 0.2.0 dump with nonempty native raw data. It preserves the entire
old snapshot in an artifact extension and refuses a change to ok or error.
It never turns an original failure into a success silently.
from copy import deepcopy
from iperf3_lib.artifacts import artifact_from_result
from iperf3_lib.result import result_from_iperf_json
def migrate_020_dump(snapshot, *, reporting_role=None):
original = deepcopy(snapshot)
if not isinstance(original, dict) or set(original) != {"ok", "error", "raw", "end"}:
raise ValueError("Review customized or unknown snapshot formats explicitly")
if type(original["ok"]) is not bool:
raise ValueError("The original execution status must be a boolean")
if original["error"] is not None and not isinstance(original["error"], str):
raise ValueError("The original error must be a string or null")
if not isinstance(original["raw"], dict) or not original["raw"]:
raise ValueError("No native document: retain the original and review manually")
parsed = result_from_iperf_json(original["raw"], reporting_role=reporting_role)
if parsed.ok != original["ok"] or parsed.error != original["error"]:
raise ValueError("Status differs: retain both records and review the migration")
artifact = artifact_from_result(parsed)
artifact.extensions["example.migration_0_2"] = {
"source_format": "iperf3-lib 0.2.0 Pydantic Result dump",
"original_snapshot": original,
}
return artifact
This is an application migration recipe, not a new package API or a universal
Pydantic decoder. Customize the extension namespace for your application.
Serialize the returned artifact with dumps_artifact() to validate its complete
JSON content, including the preserved snapshot. Its producer describes the
current artifact writer; the extension records the older source format.
Some 0.2.0 failures have no native JSON. Customized dumps may omit fields, and old successful snapshots may disagree with stricter current interpretation. These need a reviewed migration policy. Retain the original outcome and error; do not infer successful execution from partial throughput or missing metadata. Preserve any separately reviewed normalized record alongside the original rather than overwriting the old file. No measurements or completion times can be recovered when the source never recorded them.
Historical development snapshots¶
artifact_from_legacy_dict() targets the documented unversioned development
shape that preceded artifacts. It rejects unfamiliar fields and records
uncertainty, including unknown interval scope and unobserved completion.
It is not a blanket adapter for Pydantic models or arbitrary dictionaries.
The richer current to_dict() output also includes fields outside that older
adapter's accepted set. For a current in-memory Result, use
artifact_from_result() directly. See artifact migration.
New durable archives¶
from pathlib import Path
from iperf3_lib.artifacts import artifact_from_result, dumps_artifact, loads_artifact
artifact = artifact_from_result(result)
Path("benchmark-v1.json").write_text(dumps_artifact(artifact, indent=2), encoding="utf-8")
loaded = loads_artifact(Path("benchmark-v1.json").read_text(encoding="utf-8"))
restored_result = loaded.result
The artifact functions validate types and cross-field consistency, retain
producer/raw/extension evidence, and reject unsupported schema versions or
unknown canonical fields. ArtifactValidationError is a ValueError with a
data path; UnsupportedArtifactVersion identifies an unsupported schema.
Reading an archive does not load libiperf, run a benchmark, or replace recorded
environment metadata with the importing machine's values. Consult the
artifact contract before committing to a storage format.
Missing data, zero, and direction¶
SumStats.bits_per_second is now optional: absent native throughput is None.
Measured zero stays zero. Check is not None before arithmetic instead of
using truthiness or treating every missing value as zero.
flow = next((item for item in result.flows if item.direction == "client_to_server"), None)
received = flow.receiver if flow is not None else None
if not result.ok:
print("Execution did not succeed:", result.error)
elif received is None or received.bits_per_second is None:
print("Receiver throughput unavailable")
else:
print(received.bits_per_second / 1_000_000, "Mbps")
summary_mbps remains a compatibility convenience with a 0.0 fallback when
no throughput exists. It now selects the first available value, including
zero, rather than skipping zero to use another endpoint. The fallback
cannot distinguish an unavailable measurement from measured zero.
end.sum_sent and end.sum_received describe the primary flow's sender and
receiver observations. They are not two independent traffic directions and
must not be added together. Prefer explicit flows, streams, and interval
scope for richer analysis; do not combine aggregate intervals with their
component streams. Native errors and absent measured summaries produce
unsuccessful normalized results, with partial evidence and diagnostics retained.
See reading results and analysis.
Observed time and inferred time¶
Timing fields did not exist in published 0.2.0. Current execution metadata distinguishes:
- Wrapper-observed Unix start/completion events.
- Monotonic elapsed operation time.
- Native-reported start and requested duration.
- A separately labeled inferred completion estimate, where available.
Native saved JSON cannot establish a wrapper-observed completion event from
start plus requested duration. Its completed_at_seconds remains None;
an estimate stays in execution.timing.estimated_completed_at_seconds and
does not create Prometheus completion/freshness samples. Live runs record
observed timing independently. Unix timestamps can move backward after a
clock adjustment; use monotonic elapsed time for operation duration.
The legacy adapter's demotion of an old completion value to an estimate applies to unreleased development snapshots, not to published 0.2.0, which had no completion field. See execution evidence for the individual timing and configuration fields.
Keep application integration explicit¶
Applications may keep Pydantic at their own boundary, convert values, and then construct these dataclasses. The library does not require that adapter or reproduce Pydantic's model framework. Validate any manually assembled result through the artifact writer before durable storage.
Async convenience still uses executor threads, and cancelling an await alone leaves its operation running. Basic direct native calls remain non-reentrant. Expanded controls, event callbacks and explicit execution timeouts select the isolated Python/CFFI worker. This is a separate execution feature from the dataclass migration.
The expanded server API returns Result from run_once() and aserve_once();
published 0.2.0 returned None. Existing code that ignores the return value can
continue doing so. Server(port=..., bind_host=...) remains available; use
Server(config=ServerConfig(...)) for additional controls. Binding text is
validated explicitly instead of silently ignoring falsey invalid values.
serve_forever() delivers sequential attempt results to on_result, with an
optional whole-session timeout. See running servers
for failure and stop behavior.