members-csv 1.0.0__tar.gz

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.
@@ -0,0 +1,28 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ permissions:
9
+ contents: read
10
+
11
+ jobs:
12
+ test:
13
+ runs-on: ubuntu-latest
14
+ strategy:
15
+ fail-fast: false
16
+ matrix:
17
+ python-version: ["3.10", "3.11", "3.12", "3.13", "3.14", "3.15"]
18
+ steps:
19
+ - uses: actions/checkout@v4
20
+ - uses: actions/setup-python@v5
21
+ with:
22
+ python-version: ${{ matrix.python-version }}
23
+ allow-prereleases: true
24
+ - run: pip install -e '.[dev]'
25
+ - run: pytest --cov=members_csv --cov-report=term-missing
26
+ - run: ruff check .
27
+ - run: ruff format --check .
28
+ - run: mypy
@@ -0,0 +1,36 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ tags: ["v*"]
6
+
7
+ permissions:
8
+ contents: read
9
+
10
+ jobs:
11
+ build:
12
+ runs-on: ubuntu-latest
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+ - uses: actions/setup-python@v5
16
+ with:
17
+ python-version: "3.13"
18
+ - run: pip install build
19
+ - run: python -m build
20
+ - uses: actions/upload-artifact@v4
21
+ with:
22
+ name: dist
23
+ path: dist/
24
+
25
+ publish:
26
+ needs: build
27
+ runs-on: ubuntu-latest
28
+ environment: pypi
29
+ permissions:
30
+ id-token: write # PyPI trusted publishing; no API token stored
31
+ steps:
32
+ - uses: actions/download-artifact@v4
33
+ with:
34
+ name: dist
35
+ path: dist/
36
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,14 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ build/
6
+ dist/
7
+ .pytest_cache/
8
+ .mypy_cache/
9
+ .ruff_cache/
10
+ .coverage
11
+ htmlcov/
12
+
13
+ # Local data — not published (contains personal-looking records)
14
+ /members.csv
@@ -0,0 +1,15 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here.
4
+ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project uses [Semantic Versioning](https://semver.org/).
5
+
6
+ ## [Unreleased]
7
+
8
+ ## [1.0.0] - 2026-11-09
9
+
10
+ ### Added
11
+ - `load_members()` and `iter_members()`, which accept a path or a text stream.
12
+ - Immutable `Member` dataclass with a `full_name` property.
13
+ - Exception hierarchy: `MembersError`, `MissingColumnsError`, `InvalidRowError`.
14
+ - `members-csv` command with `text` and `json` output and stdin support.
15
+ - Type information (`py.typed`).
@@ -0,0 +1,19 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## Commands
6
+
7
+ Use the existing venv: `source .venv/bin/activate`. If dependencies are missing, run `pip install -e '.[dev]'`.
8
+
9
+ - `pytest` runs the tests plus the doctests in `src/` (configured through `addopts`). For a single test: `pytest tests/test_core.py::test_missing_columns`
10
+ - `ruff check . && ruff format --check .`: note that `ruff format` also formats Python code blocks inside `README.md`.
11
+ - `mypy` runs in strict mode over `src` and `tests`.
12
+
13
+ ## Conventions
14
+
15
+ - The public API is whatever `src/members_csv/__init__.py` re-exports. The README's "API reference" section documents it by hand, so update the README whenever the API changes.
16
+ - Library code raises errors and never prints. Only `cli.py` writes to stdout/stderr and turns exceptions into exit codes (1 = data/file error, 2 = usage).
17
+ - Supports Python 3.10 and newer. Don't use syntax or stdlib APIs from newer versions, even though the local venv runs 3.15.
18
+ - No runtime dependencies. Keep `dependencies = []`.
19
+ - `/members.csv` is local data that's gitignored on purpose. Never commit it. Tests use only `tests/fixtures/sample.csv`, which holds fake data with example.com addresses and documentation IP ranges.
@@ -0,0 +1,30 @@
1
+ # Contributing
2
+
3
+ ## Setup
4
+
5
+ ```bash
6
+ python -m venv .venv && source .venv/bin/activate
7
+ pip install -e '.[dev]'
8
+ ```
9
+
10
+ ## Before opening a pull request
11
+
12
+ ```bash
13
+ pytest
14
+ ruff check . && ruff format --check .
15
+ mypy
16
+ ```
17
+
18
+ CI runs the same checks on Python 3.10 through 3.15.
19
+
20
+ - Add tests for any behaviour change.
21
+ - Public functions and classes need docstrings. The README's API reference must stay in sync with them.
22
+ - Add a line under `## [Unreleased]` in `CHANGELOG.md`.
23
+
24
+ ## Releasing
25
+
26
+ 1. Bump `version` in `pyproject.toml` and move the `Unreleased` changelog entries under the new version.
27
+ 2. Commit, then tag the commit and push the tag: `git tag v1.2.3 && git push origin v1.2.3`.
28
+ 3. The `release` workflow builds the package and publishes it to PyPI.
29
+
30
+ One-time PyPI setup: on pypi.org, add a "trusted publisher" for this repository with workflow `release.yml` and environment `pypi`. No API token is needed.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Alexandru Serbanescu
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,234 @@
1
+ Metadata-Version: 2.5
2
+ Name: members-csv
3
+ Version: 1.0.0
4
+ Summary: Load member records from CSV files into typed Python objects, with a small CLI.
5
+ Project-URL: Homepage, https://github.com/AlexandruSerbanescu/members-csv
6
+ Project-URL: Issues, https://github.com/AlexandruSerbanescu/members-csv/issues
7
+ Project-URL: Changelog, https://github.com/AlexandruSerbanescu/members-csv/blob/main/CHANGELOG.md
8
+ Author: Alexandru Serbanescu
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: csv,members,parser
12
+ Classifier: Development Status :: 5 - Production/Stable
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Typing :: Typed
18
+ Requires-Python: >=3.10
19
+ Provides-Extra: dev
20
+ Requires-Dist: build>=1.2; extra == 'dev'
21
+ Requires-Dist: mypy>=1.11; extra == 'dev'
22
+ Requires-Dist: pytest-cov>=5; extra == 'dev'
23
+ Requires-Dist: pytest>=8; extra == 'dev'
24
+ Requires-Dist: ruff>=0.6; extra == 'dev'
25
+ Description-Content-Type: text/markdown
26
+
27
+ # members-csv
28
+
29
+ [![CI](https://github.com/AlexandruSerbanescu/members-csv/actions/workflows/ci.yml/badge.svg)](https://github.com/AlexandruSerbanescu/members-csv/actions/workflows/ci.yml)
30
+ ![Python](https://img.shields.io/badge/python-3.10%2B-blue)
31
+ ![License: MIT](https://img.shields.io/badge/license-MIT-green)
32
+
33
+ Load member records from CSV files into typed, immutable Python objects, from your own code or from the command line.
34
+
35
+ - **No dependencies.** Uses only the Python standard library.
36
+ - **Fully typed.** It ships `py.typed`, so mypy, Pyright and your editor see every type.
37
+ - **Clear errors.** Every error has a specific exception type with a line number, so problems never pass silently.
38
+ - **Streaming.** `iter_members()` parses large files one row at a time.
39
+
40
+ ---
41
+
42
+ ## Installation
43
+
44
+ From GitHub (works today):
45
+
46
+ ```bash
47
+ pip install git+https://github.com/AlexandruSerbanescu/members-csv.git
48
+ ```
49
+
50
+ From PyPI (once a release is published):
51
+
52
+ ```bash
53
+ pip install members-csv
54
+ ```
55
+
56
+ Requires Python 3.10 or newer.
57
+
58
+ ## Quick start
59
+
60
+ ```python
61
+ from members_csv import load_members
62
+
63
+ members = load_members("members.csv")
64
+
65
+ for m in members:
66
+ print(m.id, m.full_name, m.email)
67
+ ```
68
+
69
+ ## CSV format
70
+
71
+ The first row must be a header containing **at least** these columns, in any order. Extra columns are ignored.
72
+
73
+ | Column | Python type | Notes |
74
+ |--------------|-------------|-----------------------------------------|
75
+ | `id` | `int` | Must be an integer; otherwise `InvalidRowError` |
76
+ | `first_name` | `str` | |
77
+ | `last_name` | `str` | |
78
+ | `email` | `str` | Not validated |
79
+ | `gender` | `str` | Free text |
80
+ | `ip_address` | `str` | Not validated |
81
+
82
+ Files are read as UTF-8. Whitespace around every value is stripped.
83
+
84
+ ```csv
85
+ id,first_name,last_name,email,gender,ip_address
86
+ 1,Ada,Lovelace,ada@example.com,Female,192.0.2.1
87
+ ```
88
+
89
+ ## API reference
90
+
91
+ Everything below is importable directly from `members_csv`.
92
+
93
+ ### `load_members(source) -> list[Member]`
94
+
95
+ Reads the whole file and returns all members in file order.
96
+
97
+ ### `iter_members(source) -> Iterator[Member]`
98
+
99
+ Same as `load_members`, but yields members one at a time, so memory use stays constant for large files. Errors are raised when the bad row is reached, not up front.
100
+
101
+ **`source`** can be:
102
+
103
+ | Value | Example | Who closes it |
104
+ |----------------------------------|----------------------------------|---------------|
105
+ | `str` path | `"data/members.csv"` | the library |
106
+ | `pathlib.Path` / `os.PathLike` | `Path("members.csv")` | the library |
107
+ | Open text stream | `open(...)`, `io.StringIO(...)`, `sys.stdin` | you |
108
+
109
+ If you pass a stream you opened yourself, open it with `newline=""`, as the `csv` module recommends.
110
+
111
+ ### `class Member`
112
+
113
+ An immutable, hashable dataclass (`frozen=True, slots=True`).
114
+
115
+ | Attribute | Type |
116
+ |--------------|-------|
117
+ | `id` | `int` |
118
+ | `first_name` | `str` |
119
+ | `last_name` | `str` |
120
+ | `email` | `str` |
121
+ | `gender` | `str` |
122
+ | `ip_address` | `str` |
123
+ | `full_name` | `str` (property: `"first last"`) |
124
+
125
+ Convert a member to a dict with `dataclasses.asdict(member)`.
126
+
127
+ ### `REQUIRED_COLUMNS: tuple[str, ...]`
128
+
129
+ The column names listed in [CSV format](#csv-format).
130
+
131
+ ### Exceptions
132
+
133
+ ```
134
+ Exception
135
+ └── MembersError # catch this to handle every parsing problem
136
+ ├── MissingColumnsError # .missing: list[str] of absent column names
137
+ └── InvalidRowError # .line: int (1-based, header = 1), .reason: str
138
+ ```
139
+
140
+ `FileNotFoundError` and other `OSError`s from opening a path are raised unchanged.
141
+
142
+ ```python
143
+ from members_csv import load_members, MembersError, InvalidRowError
144
+
145
+ try:
146
+ members = load_members(path)
147
+ except FileNotFoundError:
148
+ ... # path does not exist
149
+ except InvalidRowError as e:
150
+ print(f"Bad data on line {e.line}: {e.reason}")
151
+ except MembersError as e:
152
+ print(f"Invalid members file: {e}")
153
+ ```
154
+
155
+ ## Recipes
156
+
157
+ **Look up members by email**
158
+
159
+ ```python
160
+ by_email = {m.email: m for m in load_members("members.csv")}
161
+ ```
162
+
163
+ **Filter while streaming a large file**
164
+
165
+ ```python
166
+ from members_csv import iter_members
167
+
168
+ women = [m for m in iter_members("big.csv") if m.gender == "Female"]
169
+ ```
170
+
171
+ **Parse CSV text you already have (from an HTTP response, a database, …)**
172
+
173
+ ```python
174
+ import io
175
+
176
+ members = load_members(io.StringIO(csv_text))
177
+ ```
178
+
179
+ **Export to JSON**
180
+
181
+ ```python
182
+ import dataclasses
183
+ import json
184
+
185
+ json.dumps([dataclasses.asdict(m) for m in members])
186
+ ```
187
+
188
+ ## Command-line usage
189
+
190
+ Installing the package adds a `members-csv` command. You can also run it as `python -m members_csv`.
191
+
192
+ ```console
193
+ $ members-csv members.csv
194
+ Ada Lovelace
195
+ Alan Turing
196
+
197
+ $ members-csv members.csv --format json
198
+ [
199
+ {
200
+ "id": 1,
201
+ "first_name": "Ada",
202
+ ...
203
+ }
204
+ ]
205
+
206
+ $ cat members.csv | members-csv -
207
+ $ members-csv --version
208
+ ```
209
+
210
+ | Exit code | Meaning |
211
+ |-----------|-------------------------------------------------|
212
+ | `0` | Success |
213
+ | `1` | File not found or unreadable, or the CSV is malformed (message on stderr) |
214
+ | `2` | Invalid command-line arguments |
215
+
216
+ ## Development
217
+
218
+ ```bash
219
+ git clone https://github.com/AlexandruSerbanescu/members-csv.git
220
+ cd members-csv
221
+ python -m venv .venv && source .venv/bin/activate
222
+ pip install -e '.[dev]'
223
+
224
+ pytest # tests + doctests
225
+ pytest tests/test_core.py::test_missing_columns # a single test
226
+ ruff check . && ruff format --check .
227
+ mypy
228
+ ```
229
+
230
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for the release process.
231
+
232
+ ## License
233
+
234
+ [MIT](LICENSE)
@@ -0,0 +1,208 @@
1
+ # members-csv
2
+
3
+ [![CI](https://github.com/AlexandruSerbanescu/members-csv/actions/workflows/ci.yml/badge.svg)](https://github.com/AlexandruSerbanescu/members-csv/actions/workflows/ci.yml)
4
+ ![Python](https://img.shields.io/badge/python-3.10%2B-blue)
5
+ ![License: MIT](https://img.shields.io/badge/license-MIT-green)
6
+
7
+ Load member records from CSV files into typed, immutable Python objects, from your own code or from the command line.
8
+
9
+ - **No dependencies.** Uses only the Python standard library.
10
+ - **Fully typed.** It ships `py.typed`, so mypy, Pyright and your editor see every type.
11
+ - **Clear errors.** Every error has a specific exception type with a line number, so problems never pass silently.
12
+ - **Streaming.** `iter_members()` parses large files one row at a time.
13
+
14
+ ---
15
+
16
+ ## Installation
17
+
18
+ From GitHub (works today):
19
+
20
+ ```bash
21
+ pip install git+https://github.com/AlexandruSerbanescu/members-csv.git
22
+ ```
23
+
24
+ From PyPI (once a release is published):
25
+
26
+ ```bash
27
+ pip install members-csv
28
+ ```
29
+
30
+ Requires Python 3.10 or newer.
31
+
32
+ ## Quick start
33
+
34
+ ```python
35
+ from members_csv import load_members
36
+
37
+ members = load_members("members.csv")
38
+
39
+ for m in members:
40
+ print(m.id, m.full_name, m.email)
41
+ ```
42
+
43
+ ## CSV format
44
+
45
+ The first row must be a header containing **at least** these columns, in any order. Extra columns are ignored.
46
+
47
+ | Column | Python type | Notes |
48
+ |--------------|-------------|-----------------------------------------|
49
+ | `id` | `int` | Must be an integer; otherwise `InvalidRowError` |
50
+ | `first_name` | `str` | |
51
+ | `last_name` | `str` | |
52
+ | `email` | `str` | Not validated |
53
+ | `gender` | `str` | Free text |
54
+ | `ip_address` | `str` | Not validated |
55
+
56
+ Files are read as UTF-8. Whitespace around every value is stripped.
57
+
58
+ ```csv
59
+ id,first_name,last_name,email,gender,ip_address
60
+ 1,Ada,Lovelace,ada@example.com,Female,192.0.2.1
61
+ ```
62
+
63
+ ## API reference
64
+
65
+ Everything below is importable directly from `members_csv`.
66
+
67
+ ### `load_members(source) -> list[Member]`
68
+
69
+ Reads the whole file and returns all members in file order.
70
+
71
+ ### `iter_members(source) -> Iterator[Member]`
72
+
73
+ Same as `load_members`, but yields members one at a time, so memory use stays constant for large files. Errors are raised when the bad row is reached, not up front.
74
+
75
+ **`source`** can be:
76
+
77
+ | Value | Example | Who closes it |
78
+ |----------------------------------|----------------------------------|---------------|
79
+ | `str` path | `"data/members.csv"` | the library |
80
+ | `pathlib.Path` / `os.PathLike` | `Path("members.csv")` | the library |
81
+ | Open text stream | `open(...)`, `io.StringIO(...)`, `sys.stdin` | you |
82
+
83
+ If you pass a stream you opened yourself, open it with `newline=""`, as the `csv` module recommends.
84
+
85
+ ### `class Member`
86
+
87
+ An immutable, hashable dataclass (`frozen=True, slots=True`).
88
+
89
+ | Attribute | Type |
90
+ |--------------|-------|
91
+ | `id` | `int` |
92
+ | `first_name` | `str` |
93
+ | `last_name` | `str` |
94
+ | `email` | `str` |
95
+ | `gender` | `str` |
96
+ | `ip_address` | `str` |
97
+ | `full_name` | `str` (property: `"first last"`) |
98
+
99
+ Convert a member to a dict with `dataclasses.asdict(member)`.
100
+
101
+ ### `REQUIRED_COLUMNS: tuple[str, ...]`
102
+
103
+ The column names listed in [CSV format](#csv-format).
104
+
105
+ ### Exceptions
106
+
107
+ ```
108
+ Exception
109
+ └── MembersError # catch this to handle every parsing problem
110
+ ├── MissingColumnsError # .missing: list[str] of absent column names
111
+ └── InvalidRowError # .line: int (1-based, header = 1), .reason: str
112
+ ```
113
+
114
+ `FileNotFoundError` and other `OSError`s from opening a path are raised unchanged.
115
+
116
+ ```python
117
+ from members_csv import load_members, MembersError, InvalidRowError
118
+
119
+ try:
120
+ members = load_members(path)
121
+ except FileNotFoundError:
122
+ ... # path does not exist
123
+ except InvalidRowError as e:
124
+ print(f"Bad data on line {e.line}: {e.reason}")
125
+ except MembersError as e:
126
+ print(f"Invalid members file: {e}")
127
+ ```
128
+
129
+ ## Recipes
130
+
131
+ **Look up members by email**
132
+
133
+ ```python
134
+ by_email = {m.email: m for m in load_members("members.csv")}
135
+ ```
136
+
137
+ **Filter while streaming a large file**
138
+
139
+ ```python
140
+ from members_csv import iter_members
141
+
142
+ women = [m for m in iter_members("big.csv") if m.gender == "Female"]
143
+ ```
144
+
145
+ **Parse CSV text you already have (from an HTTP response, a database, …)**
146
+
147
+ ```python
148
+ import io
149
+
150
+ members = load_members(io.StringIO(csv_text))
151
+ ```
152
+
153
+ **Export to JSON**
154
+
155
+ ```python
156
+ import dataclasses
157
+ import json
158
+
159
+ json.dumps([dataclasses.asdict(m) for m in members])
160
+ ```
161
+
162
+ ## Command-line usage
163
+
164
+ Installing the package adds a `members-csv` command. You can also run it as `python -m members_csv`.
165
+
166
+ ```console
167
+ $ members-csv members.csv
168
+ Ada Lovelace
169
+ Alan Turing
170
+
171
+ $ members-csv members.csv --format json
172
+ [
173
+ {
174
+ "id": 1,
175
+ "first_name": "Ada",
176
+ ...
177
+ }
178
+ ]
179
+
180
+ $ cat members.csv | members-csv -
181
+ $ members-csv --version
182
+ ```
183
+
184
+ | Exit code | Meaning |
185
+ |-----------|-------------------------------------------------|
186
+ | `0` | Success |
187
+ | `1` | File not found or unreadable, or the CSV is malformed (message on stderr) |
188
+ | `2` | Invalid command-line arguments |
189
+
190
+ ## Development
191
+
192
+ ```bash
193
+ git clone https://github.com/AlexandruSerbanescu/members-csv.git
194
+ cd members-csv
195
+ python -m venv .venv && source .venv/bin/activate
196
+ pip install -e '.[dev]'
197
+
198
+ pytest # tests + doctests
199
+ pytest tests/test_core.py::test_missing_columns # a single test
200
+ ruff check . && ruff format --check .
201
+ mypy
202
+ ```
203
+
204
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for the release process.
205
+
206
+ ## License
207
+
208
+ [MIT](LICENSE)
@@ -0,0 +1,52 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.25"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "members-csv"
7
+ version = "1.0.0"
8
+ description = "Load member records from CSV files into typed Python objects, with a small CLI."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ authors = [{ name = "Alexandru Serbanescu" }]
13
+ requires-python = ">=3.10"
14
+ dependencies = []
15
+ keywords = ["csv", "members", "parser"]
16
+ classifiers = [
17
+ "Development Status :: 5 - Production/Stable",
18
+ "Intended Audience :: Developers",
19
+ "Operating System :: OS Independent",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3 :: Only",
22
+ "Typing :: Typed",
23
+ ]
24
+
25
+ [project.optional-dependencies]
26
+ dev = ["pytest>=8", "pytest-cov>=5", "ruff>=0.6", "mypy>=1.11", "build>=1.2"]
27
+
28
+ [project.scripts]
29
+ members-csv = "members_csv.cli:main"
30
+
31
+ [project.urls]
32
+ Homepage = "https://github.com/AlexandruSerbanescu/members-csv"
33
+ Issues = "https://github.com/AlexandruSerbanescu/members-csv/issues"
34
+ Changelog = "https://github.com/AlexandruSerbanescu/members-csv/blob/main/CHANGELOG.md"
35
+
36
+ [tool.hatch.build.targets.wheel]
37
+ packages = ["src/members_csv"]
38
+
39
+ [tool.pytest.ini_options]
40
+ testpaths = ["src", "tests"]
41
+ addopts = ["--doctest-modules", "--import-mode=importlib"]
42
+
43
+ [tool.ruff]
44
+ line-length = 100
45
+ src = ["src", "tests"]
46
+
47
+ [tool.ruff.lint]
48
+ select = ["E", "F", "W", "I", "B", "UP", "SIM", "RUF"]
49
+
50
+ [tool.mypy]
51
+ strict = true
52
+ files = ["src", "tests"]
@@ -0,0 +1,41 @@
1
+ """Load and work with member records stored in CSV files.
2
+
3
+ Quick start::
4
+
5
+ from members_csv import load_members
6
+
7
+ for member in load_members("members.csv"):
8
+ print(member.full_name)
9
+
10
+ See :func:`load_members`, :func:`iter_members` and :class:`Member`.
11
+ """
12
+
13
+ from importlib.metadata import PackageNotFoundError, version
14
+
15
+ from .core import (
16
+ REQUIRED_COLUMNS,
17
+ InvalidRowError,
18
+ Member,
19
+ MembersError,
20
+ MissingColumnsError,
21
+ Source,
22
+ iter_members,
23
+ load_members,
24
+ )
25
+
26
+ try:
27
+ __version__ = version("members-csv")
28
+ except PackageNotFoundError: # running from a source checkout without install
29
+ __version__ = "0.0.0"
30
+
31
+ __all__ = [
32
+ "REQUIRED_COLUMNS",
33
+ "InvalidRowError",
34
+ "Member",
35
+ "MembersError",
36
+ "MissingColumnsError",
37
+ "Source",
38
+ "__version__",
39
+ "iter_members",
40
+ "load_members",
41
+ ]
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,56 @@
1
+ """Command-line interface: ``members-csv FILE [--format text|json]``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import dataclasses
7
+ import json
8
+ import sys
9
+ from collections.abc import Sequence
10
+
11
+ from . import __version__
12
+ from .core import MembersError, iter_members
13
+
14
+ EXIT_OK = 0
15
+ EXIT_ERROR = 1 # file missing/unreadable or malformed CSV (argparse uses 2 for bad usage)
16
+
17
+
18
+ def build_parser() -> argparse.ArgumentParser:
19
+ parser = argparse.ArgumentParser(
20
+ prog="members-csv",
21
+ description="Print the members listed in a CSV file.",
22
+ )
23
+ parser.add_argument("file", help="path to the CSV file, or - to read standard input")
24
+ parser.add_argument(
25
+ "--format",
26
+ choices=("text", "json"),
27
+ default="text",
28
+ help="text: one full name per line (default); json: an array of full records",
29
+ )
30
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
31
+ return parser
32
+
33
+
34
+ def main(argv: Sequence[str] | None = None) -> int:
35
+ """Run the CLI and return a process exit code."""
36
+ args = build_parser().parse_args(argv)
37
+ source = sys.stdin if args.file == "-" else args.file
38
+ try:
39
+ members = iter_members(source)
40
+ if args.format == "json":
41
+ json.dump([dataclasses.asdict(m) for m in members], sys.stdout, indent=2)
42
+ sys.stdout.write("\n")
43
+ else:
44
+ for m in members:
45
+ print(m.full_name)
46
+ except FileNotFoundError:
47
+ print(f"members-csv: error: file not found: {args.file}", file=sys.stderr)
48
+ return EXIT_ERROR
49
+ except (MembersError, OSError, UnicodeDecodeError) as exc:
50
+ print(f"members-csv: error: {exc}", file=sys.stderr)
51
+ return EXIT_ERROR
52
+ return EXIT_OK
53
+
54
+
55
+ if __name__ == "__main__":
56
+ raise SystemExit(main())
@@ -0,0 +1,164 @@
1
+ """Core parsing logic for member CSV files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import csv
6
+ import os
7
+ from collections.abc import Iterator
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import TextIO, Union
11
+
12
+ __all__ = [
13
+ "REQUIRED_COLUMNS",
14
+ "InvalidRowError",
15
+ "Member",
16
+ "MembersError",
17
+ "MissingColumnsError",
18
+ "Source",
19
+ "iter_members",
20
+ "load_members",
21
+ ]
22
+
23
+ #: Columns that every members CSV must contain (extra columns are ignored).
24
+ REQUIRED_COLUMNS: tuple[str, ...] = (
25
+ "id",
26
+ "first_name",
27
+ "last_name",
28
+ "email",
29
+ "gender",
30
+ "ip_address",
31
+ )
32
+
33
+ #: Anything :func:`load_members` / :func:`iter_members` accepts as input:
34
+ #: a filesystem path (``str`` or :class:`os.PathLike`) or an already-open
35
+ #: text stream such as ``open(...)``, :class:`io.StringIO` or ``sys.stdin``.
36
+ Source = Union[str, "os.PathLike[str]", TextIO]
37
+
38
+
39
+ class MembersError(Exception):
40
+ """Base class for every error raised by this package.
41
+
42
+ Catch this to handle all parsing problems in one place.
43
+ """
44
+
45
+
46
+ class MissingColumnsError(MembersError):
47
+ """The CSV header lacks one or more of :data:`REQUIRED_COLUMNS`."""
48
+
49
+ def __init__(self, missing: list[str]) -> None:
50
+ self.missing = missing
51
+ super().__init__(f"missing required column(s): {', '.join(missing)}")
52
+
53
+
54
+ class InvalidRowError(MembersError):
55
+ """A data row could not be converted into a :class:`Member`.
56
+
57
+ Attributes:
58
+ line: 1-based line number in the file (the header is line 1).
59
+ reason: Human-readable description of the problem.
60
+ """
61
+
62
+ def __init__(self, line: int, reason: str) -> None:
63
+ self.line = line
64
+ self.reason = reason
65
+ super().__init__(f"line {line}: {reason}")
66
+
67
+
68
+ @dataclass(frozen=True, slots=True)
69
+ class Member:
70
+ """One row of a members CSV.
71
+
72
+ Instances are immutable and hashable, so they can be used in sets
73
+ and as dictionary keys.
74
+ """
75
+
76
+ id: int
77
+ first_name: str
78
+ last_name: str
79
+ email: str
80
+ gender: str
81
+ ip_address: str
82
+
83
+ @property
84
+ def full_name(self) -> str:
85
+ """First and last name separated by a space."""
86
+ return f"{self.first_name} {self.last_name}"
87
+
88
+
89
+ def iter_members(source: Source) -> Iterator[Member]:
90
+ """Lazily yield :class:`Member` objects from a CSV source.
91
+
92
+ Use this instead of :func:`load_members` for large files, since rows are
93
+ parsed one at a time. If *source* is a path, the file is opened as UTF-8
94
+ and closed when the iterator is exhausted or garbage-collected. If it is
95
+ an open stream, the caller remains responsible for closing it.
96
+
97
+ Args:
98
+ source: Path to a CSV file, or an open text stream.
99
+
100
+ Yields:
101
+ One :class:`Member` per data row, in file order.
102
+
103
+ Raises:
104
+ FileNotFoundError: *source* is a path that does not exist.
105
+ MissingColumnsError: The header lacks a required column.
106
+ InvalidRowError: A row has an empty/non-integer ``id`` or too few fields.
107
+ """
108
+ if isinstance(source, (str, os.PathLike)):
109
+ with Path(source).open(newline="", encoding="utf-8") as f:
110
+ yield from _parse(f)
111
+ else:
112
+ yield from _parse(source)
113
+
114
+
115
+ def load_members(source: Source) -> list[Member]:
116
+ """Read every member from a CSV source into a list.
117
+
118
+ Example:
119
+ >>> import io
120
+ >>> data = io.StringIO(
121
+ ... "id,first_name,last_name,email,gender,ip_address\\n"
122
+ ... "1,Ada,Lovelace,ada@example.com,Female,192.0.2.1\\n"
123
+ ... )
124
+ >>> load_members(data)[0].full_name
125
+ 'Ada Lovelace'
126
+
127
+ Args:
128
+ source: Path to a CSV file, or an open text stream.
129
+
130
+ Returns:
131
+ All members, in file order.
132
+
133
+ Raises:
134
+ FileNotFoundError: *source* is a path that does not exist.
135
+ MissingColumnsError: The header lacks a required column.
136
+ InvalidRowError: A row is malformed.
137
+ """
138
+ return list(iter_members(source))
139
+
140
+
141
+ def _parse(stream: TextIO) -> Iterator[Member]:
142
+ reader = csv.DictReader(stream)
143
+ header = reader.fieldnames or []
144
+ missing = [c for c in REQUIRED_COLUMNS if c not in header]
145
+ if missing:
146
+ raise MissingColumnsError(missing)
147
+
148
+ for row in reader:
149
+ line = reader.line_num
150
+ if any(row.get(c) is None for c in REQUIRED_COLUMNS):
151
+ raise InvalidRowError(line, "row has fewer fields than the header")
152
+ raw_id = row["id"].strip()
153
+ try:
154
+ member_id = int(raw_id)
155
+ except ValueError:
156
+ raise InvalidRowError(line, f"id must be an integer, got {raw_id!r}") from None
157
+ yield Member(
158
+ id=member_id,
159
+ first_name=row["first_name"].strip(),
160
+ last_name=row["last_name"].strip(),
161
+ email=row["email"].strip(),
162
+ gender=row["gender"].strip(),
163
+ ip_address=row["ip_address"].strip(),
164
+ )
File without changes
@@ -0,0 +1,4 @@
1
+ id,first_name,last_name,email,gender,ip_address
2
+ 1,Ada,Lovelace,ada@example.com,Female,192.0.2.1
3
+ 2,Alan,Turing,alan@example.com,Male,192.0.2.2
4
+ 3,Grace,Hopper,grace@example.org,Female,198.51.100.3
@@ -0,0 +1,38 @@
1
+ import io
2
+ import json
3
+ from pathlib import Path
4
+
5
+ import pytest
6
+
7
+ from members_csv.cli import main
8
+
9
+ SAMPLE = str(Path(__file__).parent / "fixtures" / "sample.csv")
10
+
11
+
12
+ def test_text_output(capsys: pytest.CaptureFixture[str]) -> None:
13
+ assert main([SAMPLE]) == 0
14
+ assert capsys.readouterr().out.splitlines() == ["Ada Lovelace", "Alan Turing", "Grace Hopper"]
15
+
16
+
17
+ def test_json_output(capsys: pytest.CaptureFixture[str]) -> None:
18
+ assert main([SAMPLE, "--format", "json"]) == 0
19
+ data = json.loads(capsys.readouterr().out)
20
+ assert data[2]["email"] == "grace@example.org"
21
+
22
+
23
+ def test_stdin(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
24
+ monkeypatch.setattr("sys.stdin", io.StringIO(Path(SAMPLE).read_text()))
25
+ assert main(["-"]) == 0
26
+ assert "Grace Hopper" in capsys.readouterr().out
27
+
28
+
29
+ def test_missing_file(capsys: pytest.CaptureFixture[str]) -> None:
30
+ assert main(["nope.csv"]) == 1
31
+ assert "file not found" in capsys.readouterr().err
32
+
33
+
34
+ def test_bad_csv(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
35
+ bad = tmp_path / "bad.csv"
36
+ bad.write_text("id,first_name\n1,Ada\n")
37
+ assert main([str(bad)]) == 1
38
+ assert "missing required column" in capsys.readouterr().err
@@ -0,0 +1,90 @@
1
+ import io
2
+ from pathlib import Path
3
+
4
+ import pytest
5
+
6
+ from members_csv import (
7
+ InvalidRowError,
8
+ Member,
9
+ MembersError,
10
+ MissingColumnsError,
11
+ iter_members,
12
+ load_members,
13
+ )
14
+
15
+ SAMPLE = Path(__file__).parent / "fixtures" / "sample.csv"
16
+ HEADER = "id,first_name,last_name,email,gender,ip_address\n"
17
+
18
+
19
+ def test_load_from_path() -> None:
20
+ members = load_members(SAMPLE)
21
+ assert len(members) == 3
22
+ assert members[0] == Member(1, "Ada", "Lovelace", "ada@example.com", "Female", "192.0.2.1")
23
+
24
+
25
+ def test_load_from_str_path() -> None:
26
+ assert len(load_members(str(SAMPLE))) == 3
27
+
28
+
29
+ def test_load_from_stream() -> None:
30
+ members = load_members(io.StringIO(HEADER + "7,Ada,Lovelace,a@example.com,Female,192.0.2.1\n"))
31
+ assert members[0].id == 7
32
+
33
+
34
+ def test_full_name() -> None:
35
+ assert load_members(SAMPLE)[1].full_name == "Alan Turing"
36
+
37
+
38
+ def test_iter_is_lazy() -> None:
39
+ it = iter_members(SAMPLE)
40
+ assert next(it).first_name == "Ada"
41
+
42
+
43
+ def test_extra_columns_ignored_and_whitespace_stripped() -> None:
44
+ data = "extra," + HEADER.rstrip() + "\nx, 1 , Ada ,Lovelace,a@example.com,Female,192.0.2.1\n"
45
+ assert load_members(io.StringIO(data))[0] == Member(
46
+ 1, "Ada", "Lovelace", "a@example.com", "Female", "192.0.2.1"
47
+ )
48
+
49
+
50
+ def test_empty_file_with_header_only() -> None:
51
+ assert load_members(io.StringIO(HEADER)) == []
52
+
53
+
54
+ def test_missing_file() -> None:
55
+ with pytest.raises(FileNotFoundError):
56
+ load_members("does-not-exist.csv")
57
+
58
+
59
+ def test_missing_columns() -> None:
60
+ with pytest.raises(MissingColumnsError) as exc:
61
+ load_members(io.StringIO("id,first_name\n1,Ada\n"))
62
+ assert exc.value.missing == ["last_name", "email", "gender", "ip_address"]
63
+
64
+
65
+ def test_empty_input_reports_all_columns_missing() -> None:
66
+ with pytest.raises(MissingColumnsError):
67
+ load_members(io.StringIO(""))
68
+
69
+
70
+ def test_non_integer_id() -> None:
71
+ with pytest.raises(InvalidRowError) as exc:
72
+ load_members(io.StringIO(HEADER + "abc,Ada,Lovelace,a@example.com,Female,192.0.2.1\n"))
73
+ assert exc.value.line == 2
74
+
75
+
76
+ def test_short_row() -> None:
77
+ with pytest.raises(InvalidRowError, match="fewer fields"):
78
+ load_members(io.StringIO(HEADER + "1,Ada\n"))
79
+
80
+
81
+ def test_errors_share_base_class() -> None:
82
+ assert issubclass(MissingColumnsError, MembersError)
83
+ assert issubclass(InvalidRowError, MembersError)
84
+
85
+
86
+ def test_member_is_immutable_and_hashable() -> None:
87
+ m = load_members(SAMPLE)[0]
88
+ with pytest.raises(AttributeError):
89
+ m.id = 2 # type: ignore[misc]
90
+ assert m in {m}