Metadata-Version: 2.4
Name: pyfitparsernative
Version: 0.2.0
Classifier: Programming Language :: Rust
Classifier: Programming Language :: Python :: Implementation :: CPython
Requires-Dist: pytest ; extra == 'dev'
Requires-Dist: garmin-fit-sdk>=21.208.0 ; extra == 'dev'
Provides-Extra: dev
License-File: LICENSE
Summary: Fast FIT file parser using Rust, exposed to Python via PyO3
License: MIT
Requires-Python: >=3.9
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM

# pyfitparsernative

Fast FIT file parser using Rust, exposed to Python via PyO3.

Reads and writes Garmin FIT files using the [`rustyfit`](https://crates.io/crates/rustyfit) Rust crate. Roughly **5-10x faster** than `garmin-fit-sdk` (Python) for reading, and **~20-25x faster** for writing.

Both benchmarks below (`tests/test_performance.py`) use the same bundled real ride file (81km / 11,555 records, ~22.6k total messages across all message types) — read it once, then write the parsed result straight back out:

| Operation | garmin-fit-sdk (Python) | pyfitparsernative (Rust) | Speedup |
|-----------|--------------------------|----------------------------|---------|
| Read (`parse_fit_file`) | ~1.1-2.2s | ~0.1-0.2s | ~5-10x |
| Write (`write_fit_bytes`) | ~2.0-2.7s | ~0.1s | ~20-25x |

Numbers vary by machine/container; both benchmarks measure through the full Python API (dict marshalling included), not raw Rust-only timings.

## Installation

```bash
pip install pyfitparsernative
```

No Rust toolchain required — pre-built wheels are provided for Linux (glibc and musl), macOS (Intel and Apple Silicon), and Windows (x64). The macOS Intel (x86_64) wheel is cross-compiled and not tested in CI since GitHub no longer offers Intel macOS runners.

## Usage

```python
from pyfitparsernative import parse_fit_file, parse_fit_bytes, write_fit_file, write_fit_bytes

# Parse from a file path
messages = parse_fit_file("activity.fit")

# Parse from bytes
with open("activity.fit", "rb") as f:
    messages = parse_fit_bytes(f.read())
```

**Return type:** `list[dict[str, Any]]`

Returns a flat list of message dicts in original FIT file order. Each dict includes a `"message_type"` key (e.g. `"record"`, `"session"`, `"lap"`) alongside the field data. Timestamps are returned as naive ISO 8601 strings (`YYYY-MM-DDTHH:MM:SS`).

```python
# Example: get average power from session message
session = [m for m in messages if m["message_type"] == "session"][0]
print(session["avg_power"])    # 160
print(session["max_power"])    # 489

# Example: iterate record (data) messages
for msg in messages:
    if msg["message_type"] == "record":
        print(msg["timestamp"], msg.get("power"), msg.get("heart_rate"))
```

### Writing

`write_fit_file`/`write_fit_bytes` accept the same `list[dict[str, Any]]` shape that `parse_fit_file`/`parse_fit_bytes` return, so parsed messages can be modified and written back directly. Each dict must have a `"message_type"` key; field values use the same units/representation as the parser (naive ISO 8601 strings for timestamps, unscaled physical values for numeric fields). Input is validated up front: unrecognized field names, values outside a field's FIT type range (e.g. `heart_rate: 300`), and messages with no fields all raise `ValueError`, and a failed `write_fit_file` never leaves a partial file behind.

```python
from pyfitparsernative import write_fit_file, write_fit_bytes

messages = [
    {"message_type": "file_id", "type": 5, "manufacturer": 1, "product": 0},
    {"message_type": "workout", "sport": 1, "num_valid_steps": 1, "wkt_name": "Easy Run"},
    {
        "message_type": "workout_step",
        "message_index": 0,
        "duration_type": 0,       # time
        "duration_value": 1800000,  # ms
        "target_type": 0,         # open
        "intensity": 0,           # active
    },
]

write_fit_file("workout.fit", messages)
data = write_fit_bytes(messages)  # -> bytes
```

## Differences from garmin-fit-sdk

`pyfitparsernative` returns the same field names and numeric values as `garmin-fit-sdk`, with the following differences:

- **Timestamps** — returned as naive ISO 8601 strings instead of timezone-aware `datetime` objects.
- **Enum fields** — returned as raw integers instead of string labels.
- **Message types** — no `_mesgs` suffix (e.g. `"session"` not `"session_mesgs"`).
- **Developer fields** — garmin-fit-sdk uses integer dict keys for developer fields (e.g. `{61: 2554}`); pyfitparsernative uses string keys (`{"developer_field_61": 2554}`). Native fields missing from the FIT profile use a separate `"unknown_field_<N>"` prefix, so the two numbering spaces never collide. When writing, `developer_field_<N>` keys are re-encoded as real developer fields; the message list must contain the defining `field_description` message (with `developer_data_index`, `field_definition_number`, and `fit_base_type_id`) before the first message that uses the field — parsed files satisfy this automatically.
- **Component field expansion** — garmin-fit-sdk derives `speed` and `altitude` from the more precise `enhanced_speed`/`enhanced_altitude` fields when the base fields are absent from the message definition. pyfitparsernative applies the same expansion, so `"speed"` and `"altitude"` are always present in `record` messages alongside their `enhanced_` counterparts.
- **Unknown message types** — message numbers not in rustyfit's generated FIT profile fall back to their raw numeric string (e.g. `"147"`) instead of a name.

## Building from source

The `docker/` directory contains scripts to build and test inside containers, avoiding the need for a local Rust toolchain. These are for local development only; CI/CD uses its own pipeline.

**Build** — compiles the wheel inside `rust:1.93.1-trixie` and writes it to `dist/`:

```bash
bash docker/docker-build.sh
```

**Test** — runs the test suite inside `python:3.14.3-trixie` against the wheel in `dist/`:

```bash
bash docker/docker-test.sh
```

On Windows, run these from Git Bash or WSL. Docker Desktop must be running with Linux containers enabled.

## Releasing

Push a tag to trigger the CI/CD pipeline, which builds wheels for all platforms and publishes to PyPI:

```bash
git tag v0.1.0 && git push --tags
```

See `.github/workflows/CI.yml` for the full build matrix.

