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.
- members_csv/__init__.py +41 -0
- members_csv/__main__.py +3 -0
- members_csv/cli.py +56 -0
- members_csv/core.py +164 -0
- members_csv/py.typed +0 -0
- members_csv-1.0.0.dist-info/METADATA +234 -0
- members_csv-1.0.0.dist-info/RECORD +10 -0
- members_csv-1.0.0.dist-info/WHEEL +4 -0
- members_csv-1.0.0.dist-info/entry_points.txt +2 -0
- members_csv-1.0.0.dist-info/licenses/LICENSE +21 -0
members_csv/__init__.py
ADDED
|
@@ -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
|
+
]
|
members_csv/__main__.py
ADDED
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
|
+
[](https://github.com/AlexandruSerbanescu/members-csv/actions/workflows/ci.yml)
|
|
30
|
+

|
|
31
|
+

|
|
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,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.
|