Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# Continuous Integration workflow
# Runs tests on multiple Python versions and OS

name: CI

on:
push:
branches: [main, develop]
pull_request:
branches: [main]
workflow_dispatch: # Allow manual trigger

jobs:
test:
name: Test Python ${{ matrix.python-version }} on ${{ matrix.os }}
runs-on: ${{ matrix.os }}

strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
python-version: ['3.10', '3.11', '3.12']

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]"
pip install pytest pytest-cov

- name: Run tests with coverage
run: |
pytest --cov=labchart_parser --cov-report=xml --cov-report=term-missing

- name: Upload coverage to Codecov
if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.11'
uses: codecov/codecov-action@v4
with:
files: ./coverage.xml
fail_ci_if_error: false
verbose: true

lint:
name: Lint
runs-on: ubuntu-latest

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
cache: 'pip'

- name: Install linting tools
run: |
python -m pip install --upgrade pip
pip install ruff black isort

- name: Check formatting with Black
run: black --check --diff src/ tests/

- name: Check imports with isort
run: isort --check-only --diff --profile black src/ tests/

- name: Lint with Ruff
run: ruff check src/ tests/

build:
name: Build package
runs-on: ubuntu-latest
needs: [test, lint]

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'

- name: Install build tools
run: |
python -m pip install --upgrade pip
pip install build twine

- name: Build package
run: python -m build

- name: Check package
run: twine check dist/*

- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
68 changes: 68 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Pre-commit hooks configuration
# See https://pre-commit.com for more information
# Install: pip install pre-commit && pre-commit install

repos:
# General hooks
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.5.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-toml
- id: check-added-large-files
args: ['--maxkb=1000']
- id: check-merge-conflict
- id: detect-private-key
- id: debug-statements

# Black - Code formatter
- repo: https://github.com/psf/black
rev: 24.3.0
hooks:
- id: black
language_version: python3
args: ['--line-length=88']

# Ruff - Fast Python linter (replaces flake8, isort, etc.)
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.3.4
hooks:
# Run the linter
- id: ruff
args: ['--fix', '--exit-non-zero-on-fix']
# Run the formatter (optional, can use instead of black)
# - id: ruff-format

# isort - Import sorting (compatible with black)
- repo: https://github.com/pycqa/isort
rev: 5.13.2
hooks:
- id: isort
args: ['--profile=black']

# nbstripout - Strip output cells from Jupyter notebooks before commit.
# Notebooks with embedded outputs produce noisy diffs and balloon repo size.
- repo: https://github.com/kynan/nbstripout
rev: 0.7.1
hooks:
- id: nbstripout

# MyPy - Static type checking (optional, uncomment if needed)
# - repo: https://github.com/pre-commit/mirrors-mypy
# rev: v1.9.0
# hooks:
# - id: mypy
# additional_dependencies: [numpy, pandas-stubs]
# args: ['--ignore-missing-imports']

# Configuration for specific tools
# These can also be in pyproject.toml

# Ruff configuration (can be moved to pyproject.toml)
# [tool.ruff]
# line-length = 88
# target-version = "py310"
# select = ["E", "F", "I", "N", "W", "UP"]
# ignore = ["E501"] # Line too long (handled by black)
54 changes: 54 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Changelog

Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/);
the project follows [SemVer](https://semver.org).

## [Unreleased]

## [0.2.0] - 2026-04-29

- Per-block metadata exposed under `meta["blocks"]: list[dict]`. Top-level
`meta` mirrors block 0 for back-compat, except `ChannelTitle` (now the
DataFrame columns) and `Range`/`TopValue`/`BottomValue` (per-block only).
- `FileParsingError` raised if `ChannelTitle` differs between blocks.
- Parser rewritten around per-block segmentation + bulk `pd.read_csv`;
~320k rows/s on a synthetic 500k×5 file. Per-cell-NaN A2 behavior
preserved via `pd.to_numeric(errors="coerce")`.
- `time_abs` stitching uses each block's own `Interval_s`.
- `LabChartFile.blocks` now returns `list[int]` (Python ints, not numpy scalars).
- `matplotlib` imported lazily inside `plot_channel()` to avoid import-time
overhead and backend issues in headless environments.

## [0.1.2] - 2026-04-28

- `plot_channel()` method; matplotlib is a default dependency.
- `__version__` via `importlib.metadata`.
- `from_file()` / `parse_labchart_txt()` accept `os.PathLike`.
- `time_abs` strictly monotonic across block boundaries (advances by one
sample interval).
- A single bad cell in a numeric row becomes `NaN` instead of nullifying
the whole row.
- utf-8 read falls back to latin-1 instead of `errors="ignore"`.
- `UserWarning` when `TimeFormat` isn't `StartOfBlock`.
- Python ≥3.10 (was ≥3.8); CI on Linux/macOS/Windows × 3.10–3.12.
- Custom exceptions: `FileParsingError`, `NoDataError`, `InvalidChannelError`.
- Tests, pre-commit (black/ruff/isort/nbstripout), `[project.urls]`,
centralized tool config in `pyproject.toml`.
- Type annotations modernized (PEP 604/585).
- All error messages translated to English.

## [0.1.1] - 2024-01-15

- `get_block_comments_excluding()`, `slice_time_abs()`.
- Multi-block file support.

## [0.1.0] - 2024-01-01

- Initial release: `LabChartFile`, `parse_labchart_txt()`, block /
channel / comment extraction.

[Unreleased]: https://github.com/Neures-1158/labchart_txt_parser/compare/v0.2.0...HEAD
[0.2.0]: https://github.com/Neures-1158/labchart_txt_parser/compare/v0.1.2...v0.2.0
[0.1.2]: https://github.com/Neures-1158/labchart_txt_parser/compare/v0.1.1...v0.1.2
[0.1.1]: https://github.com/Neures-1158/labchart_txt_parser/compare/v0.1.0...v0.1.1
[0.1.0]: https://github.com/Neures-1158/labchart_txt_parser/releases/tag/v0.1.0
95 changes: 95 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project

`labchart_parser` parses ADInstruments LabChart `.txt` exports into a pandas DataFrame. Small lab tool, `src/` layout, package
`labchart_parser`.

## Commands

```bash
pip install -e ".[dev]" # dev install
pytest # all tests
pytest tests/test_parser.py::TestParserBulk -v # one class / test
ruff check src/ tests/ # lint
black --target-version py310 src/ tests/ # format
isort --profile black src/ tests/ # imports
```

CI runs Linux/macOS/Windows × Python 3.10/3.11/3.12. Floor is **Python ≥3.10**;
keep `pyproject.toml`, `README.md`, `CONTRIBUTING.md`, and the CI matrix in sync.

## Architecture

Two layers: a stateless functional parser
([src/labchart_parser/parser.py](src/labchart_parser/parser.py)) and a thin
OOP wrapper ([src/labchart_parser/core.py](src/labchart_parser/core.py)).

### Parser pipeline

`parse_labchart_txt(path) -> (df, meta)` runs three phases — read the
helper functions, not just `parse_labchart_txt`:

1. **Read.** UTF-8 first, latin-1 fallback. `errors="ignore"` is **not**
used (silently dropped bytes corrupted French/Spanish exports).
2. **Segment** (`_segment_into_blocks`). Single linear pass produces
`list[{meta, data_lines}]`. Block boundaries:
- Header line (`Interval=`, `ChannelTitle=`, `UnitName=`, `Range=`,
`TopValue=`, `BottomValue=`, `ExcelDateTime=`, `TimeFormat=`,
`DateFormat=`) appears after data lines.
- `Time` resets within a contiguous data run (covers one-header /
multi-block files; metadata is shallow-copied).
3. **Validate** (`_validate_channel_consistency`). `ChannelTitle` must
match across blocks, otherwise `FileParsingError`.
4. **Bulk-parse each block** (`_parse_block_data`). Classification pass
tags lines as full-width numeric / short comment / skip, then loads
the numeric portion via one `pd.read_csv(StringIO(buf), dtype=str,
na_values=["*"])` call. `pd.to_numeric(errors="coerce")` per column
turns unparseable cells into `NaN` without dropping the row.
5. **Stitch.** `block` ids come from segmentation order. `time_abs`
advances by each block's own `Interval_s` at boundaries (median of
diffs as fallback) — strictly monotonic.

### Metadata shape

- Top-level `meta`: block 0's `Interval`, `Interval_s`, `TimeFormat`,
`DateFormat`, `ExcelDateTime`, `UnitName`.
- `meta["blocks"]: list[dict]` — full per-block metadata.
- **Dropped** from top level: `ChannelTitle` (becomes columns),
`Range` / `TopValue` / `BottomValue` (vary per block).

### Errors

- `FileNotFoundError`: missing path.
- `FileParsingError`: bad extension, empty file, no data section,
<2 columns, ChannelTitle mismatch.
- `NoDataError`: segmentation succeeded, every block empty.
- `InvalidChannelError` (subclass of `KeyError`, raised by `core`):
unknown channel.
- `UserWarning` if `TimeFormat` isn't `StartOfBlock`.

### Wrapper invariants

`LabChartFile._data` carries the parsed frame. `channels` excludes the
`_SYSTEM_COLS` set — **add new computed columns to that tuple** or they
leak into the user-visible channel list. `get_block_df` /
`get_channel` / `slice_time_abs` return slices of `_data` **without
resetting the index**. `slice_time_abs` is inclusive on both ends.

### Test data

Integration tests use real fixtures in [examples/data/](examples/data/):

- `labchart_file.example.txt` — canonical multi-block, multi-channel.
- `labchart_file_negTime.txt` — exercises negative-time `time_block`;
do not delete (`test_negative_time_file_parses_correctly`).

For new behavior, synthesize tab-delimited fixtures via `tmp_path` —
see `TestParserErrorPaths`, `TestParserMetaBlocks`.

### Versioning

`__version__` reads from package metadata via
`importlib.metadata.version("labchart_parser")`. Bump the `version` field in `pyproject.toml` only.
Loading
Loading