lombokcsv 1.1.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.
- lombokcsv-1.1.0/LICENSE +154 -0
- lombokcsv-1.1.0/PKG-INFO +67 -0
- lombokcsv-1.1.0/README.md +49 -0
- lombokcsv-1.1.0/lombokcsv/__init__.py +42 -0
- lombokcsv-1.1.0/lombokcsv/csv.py +389 -0
- lombokcsv-1.1.0/lombokcsv/py.typed +0 -0
- lombokcsv-1.1.0/lombokcsv.egg-info/PKG-INFO +67 -0
- lombokcsv-1.1.0/lombokcsv.egg-info/SOURCES.txt +11 -0
- lombokcsv-1.1.0/lombokcsv.egg-info/dependency_links.txt +1 -0
- lombokcsv-1.1.0/lombokcsv.egg-info/top_level.txt +1 -0
- lombokcsv-1.1.0/pyproject.toml +34 -0
- lombokcsv-1.1.0/setup.cfg +4 -0
- lombokcsv-1.1.0/tests/test_api.py +212 -0
lombokcsv-1.1.0/LICENSE
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
6
|
+
|
|
7
|
+
1. Definitions.
|
|
8
|
+
|
|
9
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
10
|
+
and distribution as defined in Sections 1 through 9 of this document.
|
|
11
|
+
|
|
12
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
13
|
+
the copyright owner that is granting the License.
|
|
14
|
+
|
|
15
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
16
|
+
other entities that control, are controlled by, or are under common
|
|
17
|
+
control with that entity. For the purposes of this definition,
|
|
18
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
19
|
+
direction or management of such entity, whether by contract or
|
|
20
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
21
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
22
|
+
|
|
23
|
+
"You" (or "Your") shall mean an individual or Legal Entity exercising
|
|
24
|
+
permissions granted by this License.
|
|
25
|
+
|
|
26
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
27
|
+
including but not limited to software source code, documentation
|
|
28
|
+
source, and configuration files.
|
|
29
|
+
|
|
30
|
+
"Object" form shall mean any form resulting from mechanical
|
|
31
|
+
transformation or translation of a Source form, including but
|
|
32
|
+
not limited to compiled object code, generated documentation,
|
|
33
|
+
and conversions to other media types.
|
|
34
|
+
|
|
35
|
+
"Work" shall mean the work of authorship, whether in Source or Object
|
|
36
|
+
form, made available under the License, as indicated by a copyright
|
|
37
|
+
notice that is included in or attached to the work (an example is
|
|
38
|
+
provided in the Appendix below).
|
|
39
|
+
|
|
40
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
41
|
+
form, that is based on (or derived from) the Work and for which the
|
|
42
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
43
|
+
represent, as a whole, an original work of authorship, thus excluding
|
|
44
|
+
simple mechanical transformations or merely linking (or binding by
|
|
45
|
+
name) to the interfaces of, the Work and Derivative Works thereof.
|
|
46
|
+
|
|
47
|
+
"Contribution" shall mean any work of authorship, including
|
|
48
|
+
the original Work and any Derivative Works thereof, that is
|
|
49
|
+
intentionally submitted to, or incorporated into, the Work by
|
|
50
|
+
Licensor for inclusion in the Work by the copyright owner or by an
|
|
51
|
+
individual or Legal Entity authorized to submit on behalf of the
|
|
52
|
+
copyright owner. For the purposes of this definition, "submitted"
|
|
53
|
+
means any form of electronic, verbal, or written communication sent
|
|
54
|
+
to the Licensor or its representatives, such as but not limited to
|
|
55
|
+
communication on electronic mailing lists, source code control
|
|
56
|
+
systems, and issue tracking systems that are managed by, or on behalf
|
|
57
|
+
of, the Licensor for the purpose of discussing and improving the Work,
|
|
58
|
+
but excluding communication that is conspicuously marked or otherwise
|
|
59
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
60
|
+
|
|
61
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
62
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
63
|
+
subsequently incorporated into the Work.
|
|
64
|
+
|
|
65
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
66
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
67
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
68
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
69
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
70
|
+
Work and such Derivative Works in Source or Object form.
|
|
71
|
+
|
|
72
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
73
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
74
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
75
|
+
(except as stated in this section) patent license to make, have made,
|
|
76
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
77
|
+
where such license applies only to those patent claims licensable
|
|
78
|
+
by such Contributor that are necessarily infringed by their
|
|
79
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
80
|
+
with the Work to which such Contribution(s) was submitted.
|
|
81
|
+
|
|
82
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
83
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
84
|
+
modifications, and in Source or Object form, provided that You
|
|
85
|
+
meet the following conditions:
|
|
86
|
+
|
|
87
|
+
(a) You must give any other recipients of the Work or
|
|
88
|
+
Derivative Works a copy of this License; and
|
|
89
|
+
|
|
90
|
+
(b) You must cause any modified files to carry prominent notices
|
|
91
|
+
stating that You changed the files; and
|
|
92
|
+
|
|
93
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
94
|
+
that You distribute, all copyright, patent, trademark, and
|
|
95
|
+
attribution notices from the Source form of the Work,
|
|
96
|
+
excluding those notices that do not pertain to any part of
|
|
97
|
+
the Derivative Works; and
|
|
98
|
+
|
|
99
|
+
(d) If the Work includes a "NOTICE" text file, then any
|
|
100
|
+
Derivative Works that You distribute must include a readable
|
|
101
|
+
copy of the attribution notices contained
|
|
102
|
+
within such NOTICE file, excluding those notices that do not
|
|
103
|
+
pertain to any part of the Derivative Works, in at least one
|
|
104
|
+
of the following places: within a NOTICE text file distributed
|
|
105
|
+
as part of the Derivative Works; within the Source form or
|
|
106
|
+
documentation, if provided along with the Derivative Works; or,
|
|
107
|
+
within a display generated by the Derivative Works, if and
|
|
108
|
+
wherever such third-party notices normally appear.
|
|
109
|
+
|
|
110
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
111
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
112
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
113
|
+
this License, without any additional terms or conditions.
|
|
114
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
115
|
+
the terms of any separate license agreement you may have executed
|
|
116
|
+
with Licensor regarding such Contribution.
|
|
117
|
+
|
|
118
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
119
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
120
|
+
except as required for reasonable and customary use in describing the
|
|
121
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
122
|
+
|
|
123
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
124
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
125
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
126
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
127
|
+
implied, including, without limitation, any warranties or conditions
|
|
128
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
129
|
+
PARTICULAR PURPOSE.
|
|
130
|
+
|
|
131
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
132
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
133
|
+
unless required by applicable law (such as deliberate and grossly
|
|
134
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
135
|
+
liable to You for damages, including any direct, indirect, special,
|
|
136
|
+
incidental, or consequential damages of any character arising as a
|
|
137
|
+
result of this License or out of the use or inability to use the
|
|
138
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
139
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
140
|
+
other commercial damages or losses), even if such Contributor
|
|
141
|
+
has been advised of the possibility of such damages.
|
|
142
|
+
|
|
143
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
144
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
145
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
146
|
+
or other liability obligations and/or rights consistent with this
|
|
147
|
+
License. However, in accepting such obligations, You may act only
|
|
148
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
149
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
150
|
+
defend, and hold each Contributor harmless for any liability
|
|
151
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
152
|
+
of your accepting any such warranty or additional liability.
|
|
153
|
+
|
|
154
|
+
END OF TERMS AND CONDITIONS
|
lombokcsv-1.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: lombokcsv
|
|
3
|
+
Version: 1.1.0
|
|
4
|
+
Summary: RFC 4180 CSV parser with type detection, safe HTML tables, JSON export and GROUP BY aggregation. Pure Python, zero dependencies. Part of the Lombok Ecosystem.
|
|
5
|
+
Author: codinglombok
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/codinglombok/LombokCSV
|
|
8
|
+
Project-URL: Issues, https://github.com/codinglombok/LombokCSV/issues
|
|
9
|
+
Keywords: csv,rfc4180,parser,group-by,type-detection,zero-dependency,lombok-ecosystem
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Topic :: Text Processing :: Markup
|
|
13
|
+
Classifier: Typing :: Typed
|
|
14
|
+
Requires-Python: >=3.9
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
# LombokCSV — Python port
|
|
20
|
+
|
|
21
|
+
Python implementation of the [LombokCSV](../../README.md) contract: RFC 4180 CSV parsing, column type detection, safe HTML tables, JSON export and GROUP BY aggregation. Zero runtime dependencies.
|
|
22
|
+
|
|
23
|
+
| Item | Value |
|
|
24
|
+
|---|---|
|
|
25
|
+
| Package | `lombokcsv` (PyPI) |
|
|
26
|
+
| Version | 1.1.0 |
|
|
27
|
+
| Contract | [SPEC](../../docs/SPEC_LombokCSV_v1.1.0.md) |
|
|
28
|
+
| Vectors | runs all 152 cases in [`vectors/lombokcsv-vectors-v1.json`](../../vectors/lombokcsv-vectors-v1.json) |
|
|
29
|
+
| Acceptance | every vector case passes with byte-identical results (GP-11) |
|
|
30
|
+
| Python | >= 3.9 |
|
|
31
|
+
|
|
32
|
+
## Installation
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install lombokcsv
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The package has not been published to PyPI yet; until then install from the repository root (`pip install ./python`).
|
|
39
|
+
|
|
40
|
+
## Quick start
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from lombokcsv import CSV, AggregationOptions
|
|
44
|
+
|
|
45
|
+
csv = CSV("Region,Product,Qty\nWest,Rice,120\nWest,Corn,80\nEast,Rice,95")
|
|
46
|
+
|
|
47
|
+
csv.get_headers() # ['Region', 'Product', 'Qty']
|
|
48
|
+
csv.get_types() # ['string', 'string', 'number']
|
|
49
|
+
csv.to_json() # [{'Region': 'West', 'Product': 'Rice', 'Qty': '120'}, ...]
|
|
50
|
+
csv.to_html()
|
|
51
|
+
|
|
52
|
+
csv.aggregate(AggregationOptions(group_by="Region", sum=["Qty"], avg=["Qty"], count=True))
|
|
53
|
+
# [{'Region': 'West', 'Qty_sum': 200.0, 'Qty_avg': 100.0, 'count': 2},
|
|
54
|
+
# {'Region': 'East', 'Qty_sum': 95.0, 'Qty_avg': 95.0, 'count': 1}]
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The Python API mirrors the TypeScript reference with PEP 8 names: `toJSON` → `to_json`, `getHeaders` → `get_headers`, `className` → `class_name`, `hasHeader` → `has_header`. Behaviour, error codes (`INVALID_OPTION`, `UNKNOWN_COLUMN`) and the `[CODE] message` texts are identical across ports (SPEC section 7).
|
|
58
|
+
|
|
59
|
+
## Development
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
python -m pip install pytest coverage
|
|
63
|
+
python -m coverage run --branch --source=lombokcsv -m pytest -q
|
|
64
|
+
python -m coverage report -m --fail-under=90
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Contributions are welcome; see [CONTRIBUTING.md](../../CONTRIBUTING.md).
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# LombokCSV — Python port
|
|
2
|
+
|
|
3
|
+
Python implementation of the [LombokCSV](../../README.md) contract: RFC 4180 CSV parsing, column type detection, safe HTML tables, JSON export and GROUP BY aggregation. Zero runtime dependencies.
|
|
4
|
+
|
|
5
|
+
| Item | Value |
|
|
6
|
+
|---|---|
|
|
7
|
+
| Package | `lombokcsv` (PyPI) |
|
|
8
|
+
| Version | 1.1.0 |
|
|
9
|
+
| Contract | [SPEC](../../docs/SPEC_LombokCSV_v1.1.0.md) |
|
|
10
|
+
| Vectors | runs all 152 cases in [`vectors/lombokcsv-vectors-v1.json`](../../vectors/lombokcsv-vectors-v1.json) |
|
|
11
|
+
| Acceptance | every vector case passes with byte-identical results (GP-11) |
|
|
12
|
+
| Python | >= 3.9 |
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pip install lombokcsv
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
The package has not been published to PyPI yet; until then install from the repository root (`pip install ./python`).
|
|
21
|
+
|
|
22
|
+
## Quick start
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
from lombokcsv import CSV, AggregationOptions
|
|
26
|
+
|
|
27
|
+
csv = CSV("Region,Product,Qty\nWest,Rice,120\nWest,Corn,80\nEast,Rice,95")
|
|
28
|
+
|
|
29
|
+
csv.get_headers() # ['Region', 'Product', 'Qty']
|
|
30
|
+
csv.get_types() # ['string', 'string', 'number']
|
|
31
|
+
csv.to_json() # [{'Region': 'West', 'Product': 'Rice', 'Qty': '120'}, ...]
|
|
32
|
+
csv.to_html()
|
|
33
|
+
|
|
34
|
+
csv.aggregate(AggregationOptions(group_by="Region", sum=["Qty"], avg=["Qty"], count=True))
|
|
35
|
+
# [{'Region': 'West', 'Qty_sum': 200.0, 'Qty_avg': 100.0, 'count': 2},
|
|
36
|
+
# {'Region': 'East', 'Qty_sum': 95.0, 'Qty_avg': 95.0, 'count': 1}]
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
The Python API mirrors the TypeScript reference with PEP 8 names: `toJSON` → `to_json`, `getHeaders` → `get_headers`, `className` → `class_name`, `hasHeader` → `has_header`. Behaviour, error codes (`INVALID_OPTION`, `UNKNOWN_COLUMN`) and the `[CODE] message` texts are identical across ports (SPEC section 7).
|
|
40
|
+
|
|
41
|
+
## Development
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
python -m pip install pytest coverage
|
|
45
|
+
python -m coverage run --branch --source=lombokcsv -m pytest -q
|
|
46
|
+
python -m coverage report -m --fail-under=90
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Contributions are welcome; see [CONTRIBUTING.md](../../CONTRIBUTING.md).
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""LombokCSV - public API of the Python port."""
|
|
2
|
+
|
|
3
|
+
from lombokcsv.csv import (
|
|
4
|
+
TYPE_SAMPLE_ROWS,
|
|
5
|
+
CSV,
|
|
6
|
+
CSVAggregator,
|
|
7
|
+
CSVData,
|
|
8
|
+
CSVError,
|
|
9
|
+
CSVOptions,
|
|
10
|
+
CSVParser,
|
|
11
|
+
HTMLOptions,
|
|
12
|
+
HTMLGenerator,
|
|
13
|
+
AggregationOptions,
|
|
14
|
+
DataType,
|
|
15
|
+
detect_types,
|
|
16
|
+
escape_html,
|
|
17
|
+
is_boolean,
|
|
18
|
+
is_date,
|
|
19
|
+
is_number,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
__version__ = "1.1.0"
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"__version__",
|
|
26
|
+
"TYPE_SAMPLE_ROWS",
|
|
27
|
+
"AggregationOptions",
|
|
28
|
+
"CSV",
|
|
29
|
+
"CSVAggregator",
|
|
30
|
+
"CSVData",
|
|
31
|
+
"CSVError",
|
|
32
|
+
"CSVOptions",
|
|
33
|
+
"CSVParser",
|
|
34
|
+
"DataType",
|
|
35
|
+
"HTMLOptions",
|
|
36
|
+
"HTMLGenerator",
|
|
37
|
+
"detect_types",
|
|
38
|
+
"escape_html",
|
|
39
|
+
"is_boolean",
|
|
40
|
+
"is_date",
|
|
41
|
+
"is_number",
|
|
42
|
+
]
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
"""LombokCSV - RFC 4180 CSV parser, type detector, HTML table generator, aggregator.
|
|
2
|
+
|
|
3
|
+
Zero runtime dependencies. Normative behaviour: docs/SPEC_LombokCSV_v<version>.md.
|
|
4
|
+
The Python port consumes the same shared vector file as every other port
|
|
5
|
+
(vectors/lombokcsv-vectors-v1.json) and must produce byte-identical results.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Any, Dict, List, Optional, Sequence, Union
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"TYPE_SAMPLE_ROWS",
|
|
16
|
+
"CSV",
|
|
17
|
+
"CSVAggregator",
|
|
18
|
+
"CSVData",
|
|
19
|
+
"CSVError",
|
|
20
|
+
"CSVOptions",
|
|
21
|
+
"CSVParser",
|
|
22
|
+
"HTMLOptions",
|
|
23
|
+
"HTMLGenerator",
|
|
24
|
+
"AggregationOptions",
|
|
25
|
+
"detect_types",
|
|
26
|
+
"escape_html",
|
|
27
|
+
"is_boolean",
|
|
28
|
+
"is_date",
|
|
29
|
+
"is_number",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
TYPE_SAMPLE_ROWS = 10
|
|
33
|
+
|
|
34
|
+
NUMBER_RE = re.compile(r"^-?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$", re.ASCII)
|
|
35
|
+
DATE_RES = [
|
|
36
|
+
re.compile(r"^\d{4}-\d{2}-\d{2}$", re.ASCII),
|
|
37
|
+
re.compile(r"^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$", re.ASCII),
|
|
38
|
+
re.compile(r"^\d{1,2}/\d{1,2}/\d{4}$", re.ASCII),
|
|
39
|
+
re.compile(r"^\d{1,2}-\d{1,2}-\d{4}$", re.ASCII),
|
|
40
|
+
]
|
|
41
|
+
BOOLEAN_RE = re.compile(r"^(true|false|yes|no|1|0)$", re.ASCII | re.IGNORECASE)
|
|
42
|
+
|
|
43
|
+
DataType = str
|
|
44
|
+
|
|
45
|
+
TRAILING_BLANKS = re.compile(r"[ \t]+$")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class CSVError(Exception):
|
|
49
|
+
"""Error with a stable, language-neutral `code` (SPEC section 7)."""
|
|
50
|
+
|
|
51
|
+
def __init__(self, code: str, message: str) -> None:
|
|
52
|
+
super().__init__(message)
|
|
53
|
+
self.code = code
|
|
54
|
+
self.name = "CSVError"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class CSVOptions:
|
|
59
|
+
"""Parser options (SPEC section 1)."""
|
|
60
|
+
|
|
61
|
+
delimiter: str = ","
|
|
62
|
+
quote: str = '"'
|
|
63
|
+
has_header: Optional[bool] = None # default True
|
|
64
|
+
trim: bool = True
|
|
65
|
+
escape: Optional[str] = None # deprecated, ignored
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class CSVData:
|
|
70
|
+
headers: Optional[List[str]] = None
|
|
71
|
+
rows: List[List[str]] = field(default_factory=list)
|
|
72
|
+
types: List[DataType] = field(default_factory=list)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class AggregationOptions:
|
|
77
|
+
group_by: Union[str, int, float]
|
|
78
|
+
sum: Optional[List[Union[str, int, float]]] = None
|
|
79
|
+
avg: Optional[List[Union[str, int, float]]] = None
|
|
80
|
+
count: bool = False
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class HTMLOptions:
|
|
85
|
+
class_name: str = ""
|
|
86
|
+
id: str = ""
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def is_number(val: str) -> bool:
|
|
90
|
+
return NUMBER_RE.match(val) is not None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def is_date(val: str) -> bool:
|
|
94
|
+
return any(pattern.match(val) is not None for pattern in DATE_RES)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def is_boolean(val: str) -> bool:
|
|
98
|
+
return BOOLEAN_RE.match(val) is not None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _strip(val: str) -> str:
|
|
102
|
+
"""Strips SPACE, TAB, CR and LF from both ends (SPEC section 0.3)."""
|
|
103
|
+
return val.strip(" \t\r\n")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _single_char(name: str, value: str) -> str:
|
|
107
|
+
# SPEC section 0.4: exactly one UTF-16 code unit, i.e. one code point
|
|
108
|
+
# no greater than U+FFFF, and not CR/LF.
|
|
109
|
+
if len(value) != 1 or ord(value) > 0xFFFF or value in ("\n", "\r"):
|
|
110
|
+
raise CSVError("INVALID_OPTION", f"[INVALID_OPTION] {name} must be a single character other than CR/LF")
|
|
111
|
+
return value
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def detect_types(rows: Sequence[Sequence[str]]) -> List[DataType]:
|
|
115
|
+
"""Detects one type per column from the first TYPE_SAMPLE_ROWS rows (SPEC section 3)."""
|
|
116
|
+
num_cols = 0
|
|
117
|
+
for r in rows:
|
|
118
|
+
if len(r) > num_cols:
|
|
119
|
+
num_cols = len(r)
|
|
120
|
+
types: List[DataType] = []
|
|
121
|
+
sample = list(rows)[:TYPE_SAMPLE_ROWS]
|
|
122
|
+
for col in range(num_cols):
|
|
123
|
+
any_value = False
|
|
124
|
+
num = date = boolean = True
|
|
125
|
+
for row in sample:
|
|
126
|
+
val = _strip(row[col] if col < len(row) else "")
|
|
127
|
+
if val == "":
|
|
128
|
+
continue
|
|
129
|
+
any_value = True
|
|
130
|
+
if num and not is_number(val):
|
|
131
|
+
num = False
|
|
132
|
+
if date and not is_date(val):
|
|
133
|
+
date = False
|
|
134
|
+
if boolean and not is_boolean(val):
|
|
135
|
+
boolean = False
|
|
136
|
+
if not any_value:
|
|
137
|
+
types.append("string")
|
|
138
|
+
elif num:
|
|
139
|
+
types.append("number")
|
|
140
|
+
elif date:
|
|
141
|
+
types.append("date")
|
|
142
|
+
elif boolean:
|
|
143
|
+
types.append("boolean")
|
|
144
|
+
else:
|
|
145
|
+
types.append("string")
|
|
146
|
+
return types
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def escape_html(text: str) -> str:
|
|
150
|
+
"""Escapes text for HTML element content and double-quoted attribute values."""
|
|
151
|
+
return (
|
|
152
|
+
text.replace("&", "&")
|
|
153
|
+
.replace("<", "<")
|
|
154
|
+
.replace(">", ">")
|
|
155
|
+
.replace('"', """)
|
|
156
|
+
.replace("'", "'")
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class CSVParser:
|
|
161
|
+
"""CSV parser - RFC 4180 records with configurable delimiter and quote."""
|
|
162
|
+
|
|
163
|
+
def __init__(self, data: str, options: Optional[CSVOptions] = None) -> None:
|
|
164
|
+
options = options or CSVOptions()
|
|
165
|
+
self.data = data[1:] if data[:1] == "" else data
|
|
166
|
+
self.delimiter = _single_char("delimiter", options.delimiter)
|
|
167
|
+
self.quote = _single_char("quote", options.quote)
|
|
168
|
+
if self.delimiter == self.quote:
|
|
169
|
+
raise CSVError("INVALID_OPTION", "[INVALID_OPTION] delimiter and quote must differ")
|
|
170
|
+
self.trim = options.trim
|
|
171
|
+
|
|
172
|
+
def parse(self, has_header: bool = True) -> CSVData:
|
|
173
|
+
rows = self.parse_rows()
|
|
174
|
+
if not rows:
|
|
175
|
+
return CSVData()
|
|
176
|
+
headers = rows.pop(0) if has_header else None
|
|
177
|
+
return CSVData(headers=headers, rows=rows, types=detect_types(rows))
|
|
178
|
+
|
|
179
|
+
def parse_rows(self) -> List[List[str]]:
|
|
180
|
+
"""Splits the input into records (SPEC section 2)."""
|
|
181
|
+
data, delimiter, quote, trim = self.data, self.delimiter, self.quote, self.trim
|
|
182
|
+
rows: List[List[str]] = []
|
|
183
|
+
row: List[str] = []
|
|
184
|
+
chars: List[str] = []
|
|
185
|
+
quoted = False
|
|
186
|
+
row_quoted = False
|
|
187
|
+
# 0 = field start, 1 = unquoted, 2 = inside quotes, 3 = after closing quote
|
|
188
|
+
state = 0
|
|
189
|
+
|
|
190
|
+
def end_field() -> None:
|
|
191
|
+
nonlocal chars, quoted, row_quoted, state
|
|
192
|
+
f = "".join(chars)
|
|
193
|
+
if trim and not quoted:
|
|
194
|
+
f = TRAILING_BLANKS.sub("", f)
|
|
195
|
+
row.append(f)
|
|
196
|
+
row_quoted = row_quoted or quoted
|
|
197
|
+
chars = []
|
|
198
|
+
quoted = False
|
|
199
|
+
state = 0
|
|
200
|
+
|
|
201
|
+
def end_record() -> None:
|
|
202
|
+
nonlocal row, row_quoted
|
|
203
|
+
end_field()
|
|
204
|
+
# A line holding a single empty unquoted field is blank and skipped.
|
|
205
|
+
if len(row) > 1 or row[0] != "" or row_quoted:
|
|
206
|
+
rows.append(row)
|
|
207
|
+
row = []
|
|
208
|
+
row_quoted = False
|
|
209
|
+
|
|
210
|
+
i = 0
|
|
211
|
+
n = len(data)
|
|
212
|
+
while i < n:
|
|
213
|
+
c = data[i]
|
|
214
|
+
if state == 2:
|
|
215
|
+
if c == quote:
|
|
216
|
+
if i + 1 < n and data[i + 1] == quote:
|
|
217
|
+
chars.append(quote)
|
|
218
|
+
i += 2
|
|
219
|
+
continue
|
|
220
|
+
state = 3
|
|
221
|
+
else:
|
|
222
|
+
chars.append(c)
|
|
223
|
+
i += 1
|
|
224
|
+
continue
|
|
225
|
+
if c == delimiter:
|
|
226
|
+
end_field()
|
|
227
|
+
elif c == "\n" or c == "\r":
|
|
228
|
+
end_record()
|
|
229
|
+
if c == "\r" and i + 1 < n and data[i + 1] == "\n":
|
|
230
|
+
i += 1
|
|
231
|
+
elif state == 0:
|
|
232
|
+
if c == quote:
|
|
233
|
+
quoted = True
|
|
234
|
+
state = 2
|
|
235
|
+
elif not (trim and (c == " " or c == "\t")):
|
|
236
|
+
chars.append(c)
|
|
237
|
+
state = 1
|
|
238
|
+
elif state == 3:
|
|
239
|
+
# Lenient: text after a closing quote is appended literally.
|
|
240
|
+
if not (trim and (c == " " or c == "\t")):
|
|
241
|
+
chars.append(c)
|
|
242
|
+
else:
|
|
243
|
+
chars.append(c)
|
|
244
|
+
i += 1
|
|
245
|
+
if state != 0 or len(row) > 0:
|
|
246
|
+
end_record()
|
|
247
|
+
return rows
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
class HTMLGenerator:
|
|
251
|
+
"""HTML generator - semantic table markup (SPEC section 4)."""
|
|
252
|
+
|
|
253
|
+
def __init__(self, data: CSVData) -> None:
|
|
254
|
+
self.data = data
|
|
255
|
+
|
|
256
|
+
def generate(self, options: Optional[HTMLOptions] = None) -> str:
|
|
257
|
+
class_attr = f' class="{escape_html(options.class_name)}"' if options and options.class_name else ""
|
|
258
|
+
id_attr = f' id="{escape_html(options.id)}"' if options and options.id else ""
|
|
259
|
+
|
|
260
|
+
parts: List[str] = [f"<table{class_attr}{id_attr}>\n"]
|
|
261
|
+
if self.data.headers:
|
|
262
|
+
parts.append("<thead>\n<tr>\n")
|
|
263
|
+
for header in self.data.headers:
|
|
264
|
+
parts.append(f"<th>{escape_html(header)}</th>\n")
|
|
265
|
+
parts.append("</tr>\n</thead>\n")
|
|
266
|
+
parts.append("<tbody>\n")
|
|
267
|
+
for row in self.data.rows:
|
|
268
|
+
parts.append("<tr>\n")
|
|
269
|
+
for i, cell in enumerate(row):
|
|
270
|
+
align = ' style="text-align: right"' if i < len(self.data.types) and self.data.types[i] == "number" else ""
|
|
271
|
+
parts.append(f"<td{align}>{escape_html(cell)}</td>\n")
|
|
272
|
+
parts.append("</tr>\n")
|
|
273
|
+
parts.append("</tbody>\n</table>")
|
|
274
|
+
return "".join(parts)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
class CSVAggregator:
|
|
278
|
+
"""Aggregator - GROUP BY with SUM / COUNT / AVG (SPEC section 5)."""
|
|
279
|
+
|
|
280
|
+
def __init__(self, data: CSVData) -> None:
|
|
281
|
+
self.data = data
|
|
282
|
+
|
|
283
|
+
def _width(self) -> int:
|
|
284
|
+
w = len(self.data.headers) if self.data.headers is not None else 0
|
|
285
|
+
for r in self.data.rows:
|
|
286
|
+
if len(r) > w:
|
|
287
|
+
w = len(r)
|
|
288
|
+
return w
|
|
289
|
+
|
|
290
|
+
def _resolve(self, col: Union[str, int, float]) -> int:
|
|
291
|
+
if isinstance(col, bool):
|
|
292
|
+
raise CSVError("UNKNOWN_COLUMN", f"[UNKNOWN_COLUMN] column index out of range: {col}")
|
|
293
|
+
if isinstance(col, str):
|
|
294
|
+
idx = -1
|
|
295
|
+
if self.data.headers is not None:
|
|
296
|
+
try:
|
|
297
|
+
idx = self.data.headers.index(col)
|
|
298
|
+
except ValueError:
|
|
299
|
+
idx = -1
|
|
300
|
+
if idx < 0:
|
|
301
|
+
raise CSVError("UNKNOWN_COLUMN", f"[UNKNOWN_COLUMN] unknown column: {col}")
|
|
302
|
+
return idx
|
|
303
|
+
if isinstance(col, float):
|
|
304
|
+
if not col.is_integer():
|
|
305
|
+
raise CSVError("UNKNOWN_COLUMN", f"[UNKNOWN_COLUMN] column index out of range: {col}")
|
|
306
|
+
col = int(col)
|
|
307
|
+
if col < 0 or col >= self._width():
|
|
308
|
+
raise CSVError("UNKNOWN_COLUMN", f"[UNKNOWN_COLUMN] column index out of range: {col}")
|
|
309
|
+
return int(col)
|
|
310
|
+
|
|
311
|
+
def _name(self, idx: int) -> str:
|
|
312
|
+
if self.data.headers is not None and idx < len(self.data.headers):
|
|
313
|
+
return self.data.headers[idx]
|
|
314
|
+
return f"col_{idx}"
|
|
315
|
+
|
|
316
|
+
def aggregate(self, options: AggregationOptions) -> List[Dict[str, Any]]:
|
|
317
|
+
g = self._resolve(options.group_by)
|
|
318
|
+
sums = [self._resolve(c) for c in (options.sum or [])]
|
|
319
|
+
avgs = [self._resolve(c) for c in (options.avg or [])]
|
|
320
|
+
group_name = self._name(g)
|
|
321
|
+
|
|
322
|
+
groups: Dict[str, Dict[str, Any]] = {}
|
|
323
|
+
for row in self.data.rows:
|
|
324
|
+
key = row[g] if g < len(row) else ""
|
|
325
|
+
acc = groups.get(key)
|
|
326
|
+
if acc is None:
|
|
327
|
+
acc = {"key": key, "count": 0, "sum": [0.0] * len(sums), "avg_sum": [0.0] * len(avgs), "avg_n": [0] * len(avgs)}
|
|
328
|
+
groups[key] = acc
|
|
329
|
+
acc["count"] += 1
|
|
330
|
+
for k, c in enumerate(sums):
|
|
331
|
+
v = _strip(row[c] if c < len(row) else "")
|
|
332
|
+
if is_number(v):
|
|
333
|
+
acc["sum"][k] += float(v)
|
|
334
|
+
for k, c in enumerate(avgs):
|
|
335
|
+
v = _strip(row[c] if c < len(row) else "")
|
|
336
|
+
if is_number(v):
|
|
337
|
+
acc["avg_sum"][k] += float(v)
|
|
338
|
+
acc["avg_n"][k] += 1
|
|
339
|
+
|
|
340
|
+
out: List[Dict[str, Any]] = []
|
|
341
|
+
for acc in groups.values():
|
|
342
|
+
r: Dict[str, Any] = {group_name: acc["key"]}
|
|
343
|
+
for k, c in enumerate(sums):
|
|
344
|
+
r[f"{self._name(c)}_sum"] = acc["sum"][k]
|
|
345
|
+
for k, c in enumerate(avgs):
|
|
346
|
+
r[f"{self._name(c)}_avg"] = None if acc["avg_n"][k] == 0 else acc["avg_sum"][k] / acc["avg_n"][k]
|
|
347
|
+
if options.count:
|
|
348
|
+
r["count"] = acc["count"]
|
|
349
|
+
out.append(r)
|
|
350
|
+
return out
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
class CSV:
|
|
354
|
+
"""Main API combining parser, generator and aggregator."""
|
|
355
|
+
|
|
356
|
+
def __init__(self, data: str, options: Optional[CSVOptions] = None) -> None:
|
|
357
|
+
parser = CSVParser(data, options)
|
|
358
|
+
has_header = options.has_header if options is not None and options.has_header is not None else True
|
|
359
|
+
self._data = parser.parse(has_header)
|
|
360
|
+
|
|
361
|
+
def to_html(self, options: Optional[HTMLOptions] = None) -> str:
|
|
362
|
+
return HTMLGenerator(self._data).generate(options)
|
|
363
|
+
|
|
364
|
+
def aggregate(self, options: AggregationOptions) -> List[Dict[str, Any]]:
|
|
365
|
+
return CSVAggregator(self._data).aggregate(options)
|
|
366
|
+
|
|
367
|
+
def get_headers(self) -> Optional[List[str]]:
|
|
368
|
+
return self._data.headers
|
|
369
|
+
|
|
370
|
+
def get_rows(self) -> List[List[str]]:
|
|
371
|
+
return self._data.rows
|
|
372
|
+
|
|
373
|
+
def get_types(self) -> List[DataType]:
|
|
374
|
+
return self._data.types
|
|
375
|
+
|
|
376
|
+
def to_json(self) -> Union[List[Dict[str, str]], List[List[str]]]:
|
|
377
|
+
"""Rows as objects keyed by header (SPEC section 6). Missing cells become
|
|
378
|
+
"", extra cells are dropped, and for duplicate header names the last
|
|
379
|
+
column wins. Without a header row the raw rows are returned."""
|
|
380
|
+
headers = self._data.headers
|
|
381
|
+
if headers is None:
|
|
382
|
+
return self._data.rows
|
|
383
|
+
out: List[Dict[str, str]] = []
|
|
384
|
+
for row in self._data.rows:
|
|
385
|
+
obj: Dict[str, str] = {}
|
|
386
|
+
for i, h in enumerate(headers):
|
|
387
|
+
obj[h] = row[i] if i < len(row) else ""
|
|
388
|
+
out.append(obj)
|
|
389
|
+
return out
|
|
File without changes
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: lombokcsv
|
|
3
|
+
Version: 1.1.0
|
|
4
|
+
Summary: RFC 4180 CSV parser with type detection, safe HTML tables, JSON export and GROUP BY aggregation. Pure Python, zero dependencies. Part of the Lombok Ecosystem.
|
|
5
|
+
Author: codinglombok
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/codinglombok/LombokCSV
|
|
8
|
+
Project-URL: Issues, https://github.com/codinglombok/LombokCSV/issues
|
|
9
|
+
Keywords: csv,rfc4180,parser,group-by,type-detection,zero-dependency,lombok-ecosystem
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Topic :: Text Processing :: Markup
|
|
13
|
+
Classifier: Typing :: Typed
|
|
14
|
+
Requires-Python: >=3.9
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
# LombokCSV — Python port
|
|
20
|
+
|
|
21
|
+
Python implementation of the [LombokCSV](../../README.md) contract: RFC 4180 CSV parsing, column type detection, safe HTML tables, JSON export and GROUP BY aggregation. Zero runtime dependencies.
|
|
22
|
+
|
|
23
|
+
| Item | Value |
|
|
24
|
+
|---|---|
|
|
25
|
+
| Package | `lombokcsv` (PyPI) |
|
|
26
|
+
| Version | 1.1.0 |
|
|
27
|
+
| Contract | [SPEC](../../docs/SPEC_LombokCSV_v1.1.0.md) |
|
|
28
|
+
| Vectors | runs all 152 cases in [`vectors/lombokcsv-vectors-v1.json`](../../vectors/lombokcsv-vectors-v1.json) |
|
|
29
|
+
| Acceptance | every vector case passes with byte-identical results (GP-11) |
|
|
30
|
+
| Python | >= 3.9 |
|
|
31
|
+
|
|
32
|
+
## Installation
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install lombokcsv
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The package has not been published to PyPI yet; until then install from the repository root (`pip install ./python`).
|
|
39
|
+
|
|
40
|
+
## Quick start
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from lombokcsv import CSV, AggregationOptions
|
|
44
|
+
|
|
45
|
+
csv = CSV("Region,Product,Qty\nWest,Rice,120\nWest,Corn,80\nEast,Rice,95")
|
|
46
|
+
|
|
47
|
+
csv.get_headers() # ['Region', 'Product', 'Qty']
|
|
48
|
+
csv.get_types() # ['string', 'string', 'number']
|
|
49
|
+
csv.to_json() # [{'Region': 'West', 'Product': 'Rice', 'Qty': '120'}, ...]
|
|
50
|
+
csv.to_html()
|
|
51
|
+
|
|
52
|
+
csv.aggregate(AggregationOptions(group_by="Region", sum=["Qty"], avg=["Qty"], count=True))
|
|
53
|
+
# [{'Region': 'West', 'Qty_sum': 200.0, 'Qty_avg': 100.0, 'count': 2},
|
|
54
|
+
# {'Region': 'East', 'Qty_sum': 95.0, 'Qty_avg': 95.0, 'count': 1}]
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The Python API mirrors the TypeScript reference with PEP 8 names: `toJSON` → `to_json`, `getHeaders` → `get_headers`, `className` → `class_name`, `hasHeader` → `has_header`. Behaviour, error codes (`INVALID_OPTION`, `UNKNOWN_COLUMN`) and the `[CODE] message` texts are identical across ports (SPEC section 7).
|
|
58
|
+
|
|
59
|
+
## Development
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
python -m pip install pytest coverage
|
|
63
|
+
python -m coverage run --branch --source=lombokcsv -m pytest -q
|
|
64
|
+
python -m coverage report -m --fail-under=90
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Contributions are welcome; see [CONTRIBUTING.md](../../CONTRIBUTING.md).
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
lombokcsv/__init__.py
|
|
5
|
+
lombokcsv/csv.py
|
|
6
|
+
lombokcsv/py.typed
|
|
7
|
+
lombokcsv.egg-info/PKG-INFO
|
|
8
|
+
lombokcsv.egg-info/SOURCES.txt
|
|
9
|
+
lombokcsv.egg-info/dependency_links.txt
|
|
10
|
+
lombokcsv.egg-info/top_level.txt
|
|
11
|
+
tests/test_api.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
lombokcsv
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "lombokcsv"
|
|
7
|
+
version = "1.1.0"
|
|
8
|
+
description = "RFC 4180 CSV parser with type detection, safe HTML tables, JSON export and GROUP BY aggregation. Pure Python, zero dependencies. Part of the Lombok Ecosystem."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "Apache-2.0"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
authors = [{ name = "codinglombok" }]
|
|
13
|
+
requires-python = ">=3.9"
|
|
14
|
+
keywords = ["csv", "rfc4180", "parser", "group-by", "type-detection", "zero-dependency", "lombok-ecosystem"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Topic :: Text Processing :: Markup",
|
|
19
|
+
"Typing :: Typed",
|
|
20
|
+
]
|
|
21
|
+
dependencies = []
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://github.com/codinglombok/LombokCSV"
|
|
25
|
+
Issues = "https://github.com/codinglombok/LombokCSV/issues"
|
|
26
|
+
|
|
27
|
+
[tool.setuptools]
|
|
28
|
+
packages = ["lombokcsv"]
|
|
29
|
+
|
|
30
|
+
[tool.setuptools.package-data]
|
|
31
|
+
lombokcsv = ["py.typed"]
|
|
32
|
+
|
|
33
|
+
[tool.pytest.ini_options]
|
|
34
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Unit tests for the Python port (mirrors the TypeScript unit suite)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
import lombokcsv
|
|
8
|
+
from lombokcsv import CSV, CSVError, CSVOptions, CSVParser, AggregationOptions
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_version() -> None:
|
|
12
|
+
assert lombokcsv.__version__ == "1.1.0"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_parses_simple_csv() -> None:
|
|
16
|
+
csv = CSV("a,b,c\n1,2,3")
|
|
17
|
+
assert csv.get_rows() == [["1", "2", "3"]]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_parses_with_headers() -> None:
|
|
21
|
+
csv = CSV("Name,Age,City\nJohn,30,NYC")
|
|
22
|
+
assert csv.get_headers() == ["Name", "Age", "City"]
|
|
23
|
+
assert len(csv.get_rows()) == 1
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_handles_quoted_fields() -> None:
|
|
27
|
+
csv = CSV('"Name","Age"\n"John Smith",30')
|
|
28
|
+
assert csv.get_headers() == ["Name", "Age"]
|
|
29
|
+
assert csv.get_rows()[0][0] == "John Smith"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_handles_commas_inside_quotes() -> None:
|
|
33
|
+
csv = CSV('Name,Address\n"Smith, John","123 Main, Apt 4"')
|
|
34
|
+
assert csv.get_rows()[0][0] == "Smith, John"
|
|
35
|
+
assert csv.get_rows()[0][1] == "123 Main, Apt 4"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_handles_newlines_inside_quotes() -> None:
|
|
39
|
+
csv = CSV('Name,Description\n"John","Line 1\nLine 2"')
|
|
40
|
+
assert len(csv.get_rows()) == 1
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_parses_multiple_rows() -> None:
|
|
44
|
+
csv = CSV("A,B\n1,2\n3,4\n5,6")
|
|
45
|
+
assert len(csv.get_rows()) == 3
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_detects_types() -> None:
|
|
49
|
+
csv = CSV("Name,Value,Date,Active\nTest,123,2026-01-01,true")
|
|
50
|
+
types = csv.get_types()
|
|
51
|
+
assert types == ["string", "number", "date", "boolean"]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_boolean_detection() -> None:
|
|
55
|
+
csv = CSV("b\ntrue\nFALSE\nYes\nno")
|
|
56
|
+
assert csv.get_types() == ["boolean"]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_mixed_column_falls_back_to_string() -> None:
|
|
60
|
+
csv = CSV("m\n1\nabc")
|
|
61
|
+
assert csv.get_types() == ["string"]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_generates_html() -> None:
|
|
65
|
+
csv = CSV("Name,Age\nJohn,30")
|
|
66
|
+
html = csv.to_html()
|
|
67
|
+
assert "<table>" in html
|
|
68
|
+
assert "<thead>" in html
|
|
69
|
+
assert "<th>Name</th>" in html
|
|
70
|
+
assert '<td style="text-align: right">30</td>' in html
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_escapes_html_in_content() -> None:
|
|
74
|
+
csv = CSV("Name\n<script>alert(\"xss\")</script>")
|
|
75
|
+
html = csv.to_html()
|
|
76
|
+
assert "<script>" not in html
|
|
77
|
+
assert "<script>" in html
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_escapes_class_and_id_attributes() -> None:
|
|
81
|
+
csv = CSV("A\n1")
|
|
82
|
+
html = csv.to_html(lombokcsv.HTMLOptions(class_name='data-table', id='t1'))
|
|
83
|
+
assert 'class="data-table"' in html
|
|
84
|
+
assert 'id="t1"' in html
|
|
85
|
+
evil = CSV("A\nx").to_html(lombokcsv.HTMLOptions(class_name='" onclick="alert(1)'))
|
|
86
|
+
assert 'onclick="alert(1)"' not in evil
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_aggregation_group_by() -> None:
|
|
90
|
+
data = "Department,Employee,Salary\nIT,John,50000\nIT,Jane,55000\nSales,Bob,40000\nSales,Alice,45000"
|
|
91
|
+
csv = CSV(data)
|
|
92
|
+
result = csv.aggregate(AggregationOptions(group_by="Department", sum=["Salary"], count=True))
|
|
93
|
+
by_dept = {r["Department"]: r for r in result}
|
|
94
|
+
assert by_dept["IT"]["Salary_sum"] == 105000
|
|
95
|
+
assert by_dept["Sales"]["Salary_sum"] == 85000
|
|
96
|
+
assert by_dept["IT"]["count"] == 2
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_aggregation_avg_ignores_non_numeric() -> None:
|
|
100
|
+
csv = CSV("g,v\nx,1\nx,abc\nx,\nx,2")
|
|
101
|
+
result = csv.aggregate(AggregationOptions(group_by="g", sum=["v"], avg=["v"], count=True))
|
|
102
|
+
assert result == [{"g": "x", "v_sum": 3.0, "v_avg": 1.5, "count": 4}]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_avg_null_without_numbers() -> None:
|
|
106
|
+
csv = CSV("g,v\nx,abc")
|
|
107
|
+
result = csv.aggregate(AggregationOptions(group_by="g", sum=["v"], avg=["v"]))
|
|
108
|
+
assert result == [{"g": "x", "v_sum": 0, "v_avg": None}]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_unknown_column_error() -> None:
|
|
112
|
+
csv = CSV("a,b\n1,2")
|
|
113
|
+
with pytest.raises(CSVError) as excinfo:
|
|
114
|
+
csv.aggregate(AggregationOptions(group_by="Missing", count=True))
|
|
115
|
+
assert excinfo.value.code == "UNKNOWN_COLUMN"
|
|
116
|
+
assert str(excinfo.value) == "[UNKNOWN_COLUMN] unknown column: Missing"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_index_out_of_range_error() -> None:
|
|
120
|
+
csv = CSV("a,b\n1,2")
|
|
121
|
+
with pytest.raises(CSVError) as excinfo:
|
|
122
|
+
csv.aggregate(AggregationOptions(group_by=3))
|
|
123
|
+
assert excinfo.value.code == "UNKNOWN_COLUMN"
|
|
124
|
+
assert str(excinfo.value) == "[UNKNOWN_COLUMN] column index out of range: 3"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_invalid_option_error() -> None:
|
|
128
|
+
with pytest.raises(CSVError) as excinfo:
|
|
129
|
+
CSV("a,b", CSVOptions(delimiter=""))
|
|
130
|
+
assert excinfo.value.code == "INVALID_OPTION"
|
|
131
|
+
assert str(excinfo.value) == "[INVALID_OPTION] delimiter must be a single character other than CR/LF"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_delimiter_equals_quote_error() -> None:
|
|
135
|
+
with pytest.raises(CSVError) as excinfo:
|
|
136
|
+
CSV("a,b", CSVOptions(delimiter='"'))
|
|
137
|
+
assert excinfo.value.code == "INVALID_OPTION"
|
|
138
|
+
assert str(excinfo.value) == "[INVALID_OPTION] delimiter and quote must differ"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_json_export() -> None:
|
|
142
|
+
csv = CSV("Name,Age\nJohn,30\nJane,25")
|
|
143
|
+
assert csv.to_json() == [{"Name": "John", "Age": "30"}, {"Name": "Jane", "Age": "25"}]
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_json_without_header() -> None:
|
|
147
|
+
csv = CSV("1,2\n3,4", lombokcsv.CSVOptions(has_header=False))
|
|
148
|
+
assert csv.to_json() == [["1", "2"], ["3", "4"]]
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def test_duplicate_headers_last_wins() -> None:
|
|
152
|
+
csv = CSV("a,a\n1,2")
|
|
153
|
+
assert csv.to_json() == [{"a": "2"}]
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_missing_cells_fill_empty_string() -> None:
|
|
157
|
+
csv = CSV("a,b\n1")
|
|
158
|
+
assert csv.to_json() == [{"a": "1", "b": ""}]
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def test_empty_csv() -> None:
|
|
162
|
+
csv = CSV("")
|
|
163
|
+
assert csv.get_rows() == []
|
|
164
|
+
assert csv.get_headers() is None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def test_windows_line_endings() -> None:
|
|
168
|
+
csv = CSV("A,B\r\n1,2\r\n3,4")
|
|
169
|
+
assert len(csv.get_rows()) == 2
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def test_bom_is_removed() -> None:
|
|
173
|
+
csv = CSV("a,b\n1,2")
|
|
174
|
+
assert csv.get_headers() == ["a", "b"]
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def test_blank_lines_are_skipped() -> None:
|
|
178
|
+
csv = CSV("a,b\n\n1,2")
|
|
179
|
+
assert csv.get_rows() == [["1", "2"]]
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def test_unterminated_quote_is_lenient() -> None:
|
|
183
|
+
parser = CSVParser('"unterminated,x\ny')
|
|
184
|
+
assert parser.parse_rows() == [["unterminated,x\ny"]]
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def test_parser_parse_rows_includes_header_row() -> None:
|
|
188
|
+
parser = CSVParser("a,b\n1,2")
|
|
189
|
+
assert parser.parse_rows() == [["a", "b"], ["1", "2"]]
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def test_trim_false_keeps_blanks() -> None:
|
|
193
|
+
parser = CSVParser(" a , b ", CSVOptions(trim=False))
|
|
194
|
+
assert parser.parse_rows() == [[" a ", " b "]]
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def test_quoted_content_is_never_trimmed() -> None:
|
|
198
|
+
parser = CSVParser('" a ",b')
|
|
199
|
+
assert parser.parse_rows() == [[" a ", "b"]]
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def test_custom_delimiter_and_quote() -> None:
|
|
203
|
+
parser = CSVParser("'a,b',c", CSVOptions(quote="'"))
|
|
204
|
+
assert parser.parse_rows() == [["a,b", "c"]]
|
|
205
|
+
parser2 = CSVParser("a;b;c\n1;2;3", CSVOptions(delimiter=";"))
|
|
206
|
+
assert parser2.parse_rows() == [["a", "b", "c"], ["1", "2", "3"]]
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def test_large_csv() -> None:
|
|
210
|
+
rows = "\n".join(f"{i},value_{i}" for i in range(1000))
|
|
211
|
+
csv = CSV(f"ID,Value\n{rows}")
|
|
212
|
+
assert len(csv.get_rows()) == 1000
|