members-csv 1.0.0__py3-none-any.whl

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,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())
members_csv/cli.py ADDED
@@ -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())
members_csv/core.py ADDED
@@ -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
+ )
members_csv/py.typed ADDED
File without changes
@@ -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,10 @@
1
+ members_csv/__init__.py,sha256=PK60XE3kcXHllcNk4NavqxqQSLN6R0pSAgs_eHWy5uI,847
2
+ members_csv/__main__.py,sha256=k1ocEWawweo1qCJWNFAAvyxz3tcY13dzvCenHszij30,48
3
+ members_csv/cli.py,sha256=HGJ2iBZ_IPk5NsHLuGfjWRCWH3nCujVovLg1B8gLuxw,1816
4
+ members_csv/core.py,sha256=d49z2sJ1Dnnp6HmLvCmc37Uder_AOOznODn9ntYXs-k,4870
5
+ members_csv/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ members_csv-1.0.0.dist-info/METADATA,sha256=hDpj7xvlrpZeGMmMtrF8zWjGFuXVz4P8nUwljgO6vfg,6983
7
+ members_csv-1.0.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
8
+ members_csv-1.0.0.dist-info/entry_points.txt,sha256=fDdMloYKb-fD6pw4W8T-dXv4y5J0fpIqtwtkvG8Ti3E,53
9
+ members_csv-1.0.0.dist-info/licenses/LICENSE,sha256=n1Bs9Yd_hsXQ-3YCaM-i8Gsqa-bOFzoQ2JlBXP4xFJI,1077
10
+ members_csv-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ members-csv = members_csv.cli:main
@@ -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.