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.
@@ -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
@@ -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("&", "&amp;")
153
+ .replace("<", "&lt;")
154
+ .replace(">", "&gt;")
155
+ .replace('"', "&quot;")
156
+ .replace("'", "&#39;")
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
+ 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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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 "&lt;script&gt;" 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