flat-file-renderers 0.1.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.
- flat_file_renderers/__init__.py +32 -0
- flat_file_renderers/base.py +114 -0
- flat_file_renderers/flat_dict_list_helper.py +205 -0
- flat_file_renderers/html.py +88 -0
- flat_file_renderers/json.py +75 -0
- flat_file_renderers/parquet.py +68 -0
- flat_file_renderers/separated_value.py +112 -0
- flat_file_renderers/text.py +7 -0
- flat_file_renderers/xlsx.py +64 -0
- flat_file_renderers-0.1.0.dist-info/METADATA +146 -0
- flat_file_renderers-0.1.0.dist-info/RECORD +15 -0
- flat_file_renderers-0.1.0.dist-info/WHEEL +5 -0
- flat_file_renderers-0.1.0.dist-info/licenses/AUTHORS.md +3 -0
- flat_file_renderers-0.1.0.dist-info/licenses/LICENSE.md +23 -0
- flat_file_renderers-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""flat_file_renderers - render tabular/nested datasets into xlsx, csv, tsv, json, html or
|
|
2
|
+
parquet files, zipped.
|
|
3
|
+
|
|
4
|
+
Only the dependency-free renderers (csv, tsv, json, html) are exported here, so importing this
|
|
5
|
+
package never requires openpyxl or pyarrow. XlsxRenderer and ParquetRenderer are available from
|
|
6
|
+
their own submodules and require the matching optional extra:
|
|
7
|
+
|
|
8
|
+
pip install flat-file-renderers[xlsx] # from flat_file_renderers.xlsx import XlsxRenderer
|
|
9
|
+
pip install flat-file-renderers[parquet] # from flat_file_renderers.parquet import ParquetRenderer
|
|
10
|
+
pip install flat-file-renderers[all] # both
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from flat_file_renderers.base import BaseRenderer, ZipFileEntry
|
|
14
|
+
from flat_file_renderers.flat_dict_list_helper import FlatDictListHelper
|
|
15
|
+
from flat_file_renderers.html import HtmlRenderer
|
|
16
|
+
from flat_file_renderers.json import JsonRenderer
|
|
17
|
+
from flat_file_renderers.separated_value import CsvRenderer, FlatDelimitedRenderer, TsvRenderer
|
|
18
|
+
from flat_file_renderers.text import replace_crlf
|
|
19
|
+
|
|
20
|
+
__version__ = "0.1.0"
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"BaseRenderer",
|
|
24
|
+
"ZipFileEntry",
|
|
25
|
+
"FlatDictListHelper",
|
|
26
|
+
"replace_crlf",
|
|
27
|
+
"CsvRenderer",
|
|
28
|
+
"TsvRenderer",
|
|
29
|
+
"FlatDelimitedRenderer",
|
|
30
|
+
"JsonRenderer",
|
|
31
|
+
"HtmlRenderer",
|
|
32
|
+
]
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from abc import abstractmethod
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from io import BytesIO
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from tempfile import NamedTemporaryFile
|
|
7
|
+
from zipfile import ZIP_DEFLATED, ZipFile
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ZipFileEntry:
|
|
13
|
+
def __init__(self, zipfilealias: str, osfilepath: Path):
|
|
14
|
+
self.zipfilealias = zipfilealias
|
|
15
|
+
self.osfilepath = osfilepath
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class BaseRenderer:
|
|
19
|
+
"""
|
|
20
|
+
Base class for dataset renderers.
|
|
21
|
+
|
|
22
|
+
Defines the interface and basic structure for concrete renderers (CSV, Excel, Parquet, etc).
|
|
23
|
+
Subclasses must implement the abstract `render` method to provide the file-generation logic.
|
|
24
|
+
|
|
25
|
+
Attributes:
|
|
26
|
+
context (dict): Context dict used to render the file. May hold input data, a validated
|
|
27
|
+
form, the requesting user, etc.
|
|
28
|
+
|
|
29
|
+
Example:
|
|
30
|
+
class CsvRenderer(BaseRenderer):
|
|
31
|
+
def render(self, *args, **kwargs) -> BytesIO:
|
|
32
|
+
...
|
|
33
|
+
|
|
34
|
+
renderer = CsvRenderer({'columns': ["Name", "Age"], 'rows': [{"Name": "Alice", "Age": "30"}]})
|
|
35
|
+
csv_file = renderer.render()
|
|
36
|
+
with open("data.csv", "wb") as f:
|
|
37
|
+
f.write(csv_file.getvalue())
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, context: dict[str, any]):
|
|
41
|
+
"""
|
|
42
|
+
Initializes the base renderer with the given context.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
context (dict): Context dict holding whatever the renderer needs to produce the file.
|
|
46
|
+
"""
|
|
47
|
+
self.context = context
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def default_filename(self) -> str:
|
|
51
|
+
"""
|
|
52
|
+
Builds a default filename from the current date/time and the renderer's extension.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
str: Suggested filename.
|
|
56
|
+
"""
|
|
57
|
+
return f"dataset_{datetime.now().strftime('%Y-%m-%d-%H-%M-%S')}.{self.extension}"
|
|
58
|
+
|
|
59
|
+
def write_zipfile(self, entries: list["ZipFileEntry"]) -> Path:
|
|
60
|
+
"""
|
|
61
|
+
Compresses one or more files into a temporary ZIP file.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
entries (list[ZipFileEntry]): Entries to compress.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
Path: Path to the generated ZIP file.
|
|
68
|
+
|
|
69
|
+
Examples:
|
|
70
|
+
```python
|
|
71
|
+
zip_path = renderer.write_zipfile([ZipFileEntry("data.csv", Path("/path/to/data.csv"))])
|
|
72
|
+
|
|
73
|
+
with open(zip_path, "rb") as f:
|
|
74
|
+
zip_content = f.read()
|
|
75
|
+
|
|
76
|
+
from zipfile import ZipFile
|
|
77
|
+
with ZipFile(zip_path, 'r') as zipf:
|
|
78
|
+
print(zipf.namelist())
|
|
79
|
+
# ['data.csv']
|
|
80
|
+
```
|
|
81
|
+
"""
|
|
82
|
+
with NamedTemporaryFile(delete=False, suffix=".zip") as zipfilehandler:
|
|
83
|
+
logger.info(
|
|
84
|
+
"Creating temporary zip file to compress: %s",
|
|
85
|
+
[entry.zipfilealias for entry in entries],
|
|
86
|
+
)
|
|
87
|
+
with ZipFile(zipfilehandler, mode="w", compression=ZIP_DEFLATED) as archive:
|
|
88
|
+
for entry in entries:
|
|
89
|
+
logger.info("Adding file %s to zip as %s", entry.osfilepath, entry.zipfilealias)
|
|
90
|
+
archive.write(entry.osfilepath, arcname=entry.zipfilealias)
|
|
91
|
+
logger.info("Zip file created successfully: %s", zipfilehandler.name)
|
|
92
|
+
return Path(zipfilehandler.name)
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def extension(self) -> str:
|
|
96
|
+
"""
|
|
97
|
+
Returns the default file extension produced by this renderer.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
str: File extension (e.g. 'csv', 'xlsx').
|
|
101
|
+
"""
|
|
102
|
+
return getattr(self, "_extension", "dat")
|
|
103
|
+
|
|
104
|
+
@abstractmethod
|
|
105
|
+
def render(self, *args, **kwargs) -> BytesIO:
|
|
106
|
+
"""
|
|
107
|
+
Abstract method that renders the data and produces the file.
|
|
108
|
+
|
|
109
|
+
Subclasses must implement this to provide the format-specific generation logic.
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
BytesIO: In-memory generated file.
|
|
113
|
+
"""
|
|
114
|
+
...
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def _as_plain_dict(row: Any) -> Any:
|
|
5
|
+
"""Returns `row` as a plain dict when it looks like a model/record instance.
|
|
6
|
+
|
|
7
|
+
Duck-typed on purpose: rather than importing Django (or any other ORM) to `isinstance`-check
|
|
8
|
+
for a `Model`, any non-dict object exposing `__dict__` (Django models, dataclasses without
|
|
9
|
+
`__slots__`, plain objects, etc.) is treated as a record and flattened via its `__dict__`.
|
|
10
|
+
Dicts and anything without `__dict__` (namedtuples, plain values) pass through unchanged.
|
|
11
|
+
"""
|
|
12
|
+
if not isinstance(row, dict) and hasattr(row, "__dict__"):
|
|
13
|
+
return row.__dict__
|
|
14
|
+
return row
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class FlatDictListHelper:
|
|
18
|
+
"""
|
|
19
|
+
Helper for renderers that deal with lists of plain dicts (or dict-like records), without
|
|
20
|
+
deeply nested structures.
|
|
21
|
+
|
|
22
|
+
Provides methods to expand lists of dicts into flat columns and to normalize keys, so that
|
|
23
|
+
renderers can handle tabular data consistently and without naming collisions. Particularly
|
|
24
|
+
useful for renderers like CSV/TSV, which need to turn complex data into simple tabular rows.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
@staticmethod
|
|
28
|
+
def extract_rows_and_columns(context: dict[str, Any]) -> tuple[list[Any], list[str]]:
|
|
29
|
+
"""
|
|
30
|
+
Extracts the rows and columns of a dataset present in the rendering context.
|
|
31
|
+
|
|
32
|
+
Accepts a dataset shaped as a dict (with 'rows'/'cols'), a list, or any other iterable
|
|
33
|
+
(e.g. a Django QuerySet or generator) - duck-typed via `__iter__`, no ORM import needed.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
context (dict): Context containing the 'dataset' key.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
tuple[list, list]: Tuple of (rows, column names - if available).
|
|
40
|
+
|
|
41
|
+
Raises:
|
|
42
|
+
ValueError: If the context has no 'dataset' key, or its type isn't supported.
|
|
43
|
+
"""
|
|
44
|
+
if "dataset" not in context:
|
|
45
|
+
raise ValueError("context must contain a 'dataset' key")
|
|
46
|
+
|
|
47
|
+
dataset = context.get("dataset", None)
|
|
48
|
+
|
|
49
|
+
if isinstance(dataset, dict):
|
|
50
|
+
rows: list[Any] = dataset.get("rows", [])
|
|
51
|
+
elif isinstance(dataset, (list, tuple)):
|
|
52
|
+
rows = list(dataset)
|
|
53
|
+
elif dataset is not None and not isinstance(dataset, (str, bytes)) and hasattr(dataset, "__iter__"):
|
|
54
|
+
rows = list(dataset)
|
|
55
|
+
else:
|
|
56
|
+
raise ValueError("dataset must be a QuerySet-like iterable, dict, or list")
|
|
57
|
+
|
|
58
|
+
cols: list[str] = dataset.get("cols", []) if isinstance(dataset, dict) else []
|
|
59
|
+
return rows, cols
|
|
60
|
+
|
|
61
|
+
@staticmethod
|
|
62
|
+
def deep_flatten(row: dict, parent_key: str = "") -> dict:
|
|
63
|
+
"""
|
|
64
|
+
Recursively expands nested dicts and lists of dicts into a flat dict, using dot notation
|
|
65
|
+
for nested keys.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
row (dict): Data row, possibly containing plain values, lists of dicts, or nested dicts.
|
|
69
|
+
parent_key (str): Key prefix (used during recursion).
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
dict: Flat dict with all keys expanded.
|
|
73
|
+
|
|
74
|
+
Example:
|
|
75
|
+
deep_flatten({"name": "Alice", "contacts": [{"phone": "1234"}, {"phone": "5678"}]})
|
|
76
|
+
-> {"name": "Alice", "contacts.1.phone": "1234", "contacts.2.phone": "5678"}
|
|
77
|
+
"""
|
|
78
|
+
items = {}
|
|
79
|
+
flat = FlatDictListHelper.expand_row(row)
|
|
80
|
+
for k, v in flat.items():
|
|
81
|
+
new_key = f"{parent_key}.{k}" if parent_key else str(k)
|
|
82
|
+
if isinstance(v, dict):
|
|
83
|
+
items.update(FlatDictListHelper.deep_flatten(v, new_key))
|
|
84
|
+
else:
|
|
85
|
+
items[new_key] = v
|
|
86
|
+
return items
|
|
87
|
+
|
|
88
|
+
@staticmethod
|
|
89
|
+
def extract_flat_columns(rows: list) -> list[str]:
|
|
90
|
+
"""
|
|
91
|
+
Given a list of dicts (or record objects), returns the list of flat, normalized columns,
|
|
92
|
+
including columns expanded from lists of dicts.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
rows (list): List of dicts or record/model objects representing the data rows.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
list[str]: List of column names, including columns expanded from lists of dicts,
|
|
99
|
+
with normalized keys.
|
|
100
|
+
|
|
101
|
+
Example:
|
|
102
|
+
rows = [
|
|
103
|
+
{"name": "Alice", "contacts": [{"phone": "1234"}, {"phone": "5678"}]},
|
|
104
|
+
{"name": "Bob", "addresses": [{"street": "Street A"}, {"street": "Street B"}]}
|
|
105
|
+
]
|
|
106
|
+
# Output: ["name", "contacts.1.phone", "contacts.2.phone", "addresses.1.street", "addresses.2.street"]
|
|
107
|
+
"""
|
|
108
|
+
list_dict_max = {} # key: max_len
|
|
109
|
+
list_dict_keys = {} # key: set(subkeys)
|
|
110
|
+
for row in rows:
|
|
111
|
+
row = _as_plain_dict(row)
|
|
112
|
+
for key, value in row.items():
|
|
113
|
+
if isinstance(value, list) and value and all(isinstance(x, dict) for x in value):
|
|
114
|
+
list_dict_max[key] = max(list_dict_max.get(key, 0), len(value))
|
|
115
|
+
subkeys = set()
|
|
116
|
+
for item in value:
|
|
117
|
+
subkeys.update(item.keys())
|
|
118
|
+
if key not in list_dict_keys:
|
|
119
|
+
list_dict_keys[key] = set()
|
|
120
|
+
list_dict_keys[key].update(subkeys)
|
|
121
|
+
|
|
122
|
+
cols = set()
|
|
123
|
+
for row in rows:
|
|
124
|
+
row = _as_plain_dict(row)
|
|
125
|
+
flat = FlatDictListHelper.expand_row(row)
|
|
126
|
+
for key in flat.keys():
|
|
127
|
+
if key not in list_dict_max:
|
|
128
|
+
cols.add(FlatDictListHelper.normalize_key(key))
|
|
129
|
+
for key, max_len in list_dict_max.items():
|
|
130
|
+
for idx in range(1, max_len + 1):
|
|
131
|
+
for subkey in list_dict_keys[key]:
|
|
132
|
+
cols.add(FlatDictListHelper.normalize_key(f"{key}.{idx}.{subkey}"))
|
|
133
|
+
return sorted(cols)
|
|
134
|
+
|
|
135
|
+
@staticmethod
|
|
136
|
+
def expand_row(row: dict) -> dict:
|
|
137
|
+
"""
|
|
138
|
+
Expands lists of dicts into flat columns.
|
|
139
|
+
|
|
140
|
+
Discovers all columns, including expanded lists of dicts, and normalizes keys to avoid
|
|
141
|
+
collisions. Lists of dicts are expanded into dot+index-notation columns, and keys are
|
|
142
|
+
normalized by replacing '__' with '.'.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
row (dict): Data row, holding plain values or lists of dicts.
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
dict: Flat dict where lists of dicts have been expanded into dot+index-notation columns.
|
|
149
|
+
|
|
150
|
+
Example:
|
|
151
|
+
expand_row({"name": "Alice", "contacts": [{"phone": "1234"}, {"phone": "5678"}]})
|
|
152
|
+
-> {"name": "Alice", "contacts.1.phone": "1234", "contacts.2.phone": "5678"}
|
|
153
|
+
"""
|
|
154
|
+
flat = {}
|
|
155
|
+
for key, value in row.items():
|
|
156
|
+
if isinstance(value, list) and value and all(isinstance(x, dict) for x in value):
|
|
157
|
+
for idx, item in enumerate(value, 1):
|
|
158
|
+
for subkey, subval in item.items():
|
|
159
|
+
flat[f"{key}.{idx}.{subkey}"] = subval
|
|
160
|
+
else:
|
|
161
|
+
flat[key] = value
|
|
162
|
+
return flat
|
|
163
|
+
|
|
164
|
+
@staticmethod
|
|
165
|
+
def normalize_key(key: str) -> str:
|
|
166
|
+
"""
|
|
167
|
+
Replaces '__' with '.' in keys, to avoid clashing with the list-of-dicts dot notation.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
key (str): Key to normalize.
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
str: Normalized key, with '__' replaced by '.'.
|
|
174
|
+
|
|
175
|
+
Example:
|
|
176
|
+
normalize_key("address__street") -> "address.street"
|
|
177
|
+
normalize_key("person__contacts__1__phone") -> "person.contacts.1.phone"
|
|
178
|
+
"""
|
|
179
|
+
return key.replace("__", ".")
|
|
180
|
+
|
|
181
|
+
@staticmethod
|
|
182
|
+
def flatten_dataset(context: dict[str, Any]) -> tuple[list[dict], list[str]]:
|
|
183
|
+
"""
|
|
184
|
+
Returns a flat version of the context's dataset, with all rows and columns expanded and
|
|
185
|
+
normalized.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
context (dict): Context containing the 'dataset' key.
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
tuple[list[dict], list[str]]: List of flat rows and list of column names.
|
|
192
|
+
"""
|
|
193
|
+
flattened_rows = []
|
|
194
|
+
all_keys = set()
|
|
195
|
+
rows, _ = FlatDictListHelper.extract_rows_and_columns(context)
|
|
196
|
+
for row in rows:
|
|
197
|
+
row = _as_plain_dict(row)
|
|
198
|
+
flat = FlatDictListHelper.deep_flatten(row)
|
|
199
|
+
normalized_flat = {FlatDictListHelper.normalize_key(k): v for k, v in flat.items()}
|
|
200
|
+
flattened_rows.append(normalized_flat)
|
|
201
|
+
all_keys.update(normalized_flat.keys())
|
|
202
|
+
|
|
203
|
+
cols = list(all_keys)
|
|
204
|
+
cols.sort()
|
|
205
|
+
return flattened_rows, cols
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from html import escape
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from tempfile import NamedTemporaryFile
|
|
5
|
+
|
|
6
|
+
from flat_file_renderers.base import BaseRenderer, ZipFileEntry
|
|
7
|
+
from flat_file_renderers.flat_dict_list_helper import FlatDictListHelper
|
|
8
|
+
from flat_file_renderers.text import replace_crlf
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class HtmlRenderer(BaseRenderer):
|
|
14
|
+
"""
|
|
15
|
+
Renders data as HTML (an HTML table).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
_extension = "html"
|
|
19
|
+
|
|
20
|
+
def render(self, context: dict[str, any], *args, **kwargs) -> Path:
|
|
21
|
+
"""Renders the data as HTML and writes it into a temporary zip file on disk.
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
Path: Path to the compressed (zip) file containing the .html file(s).
|
|
25
|
+
"""
|
|
26
|
+
if "dataset" not in context:
|
|
27
|
+
raise ValueError("context must contain a 'dataset' key")
|
|
28
|
+
|
|
29
|
+
dataset = context.get("dataset")
|
|
30
|
+
|
|
31
|
+
def dump_html_file(title: str, sub_dataset: any) -> Path:
|
|
32
|
+
html_parts = [
|
|
33
|
+
"<!DOCTYPE html>",
|
|
34
|
+
"<html lang='en'>",
|
|
35
|
+
"<head>",
|
|
36
|
+
" <meta charset='utf-8'>",
|
|
37
|
+
f" <title>{escape(str(title or 'Report'))}</title>",
|
|
38
|
+
" <style>",
|
|
39
|
+
" body { font-family: Arial, sans-serif; margin: 20px; color: #333; }",
|
|
40
|
+
" h2 { color: #0056b3; border-bottom: 2px solid #0056b3; padding-bottom: 5px; }",
|
|
41
|
+
" table { border-collapse: collapse; width: 100%; margin-bottom: 30px; font-size: 14px; }",
|
|
42
|
+
" th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }",
|
|
43
|
+
" th { background-color: #f2f2f2; font-weight: bold; }",
|
|
44
|
+
" tr:nth-child(even) { background-color: #f9f9f9; }",
|
|
45
|
+
" </style>",
|
|
46
|
+
"</head>",
|
|
47
|
+
"<body>",
|
|
48
|
+
]
|
|
49
|
+
if title:
|
|
50
|
+
html_parts.append(f"<h2>{escape(str(title))}</h2>")
|
|
51
|
+
|
|
52
|
+
rows, cols = FlatDictListHelper.flatten_dataset({"dataset": sub_dataset})
|
|
53
|
+
html_parts.append("<table>")
|
|
54
|
+
html_parts.append(" <thead><tr>")
|
|
55
|
+
for col in cols:
|
|
56
|
+
html_parts.append(f" <th>{escape(str(col))}</th>")
|
|
57
|
+
html_parts.append(" </tr></thead>")
|
|
58
|
+
html_parts.append(" <tbody>")
|
|
59
|
+
for row in rows:
|
|
60
|
+
# `rows` always comes from FlatDictListHelper.flatten_dataset(), which always
|
|
61
|
+
# produces dicts - so every row here is a dict.
|
|
62
|
+
html_parts.append(" <tr>")
|
|
63
|
+
for col in cols:
|
|
64
|
+
val = row.get(col, "")
|
|
65
|
+
val_str = replace_crlf(str(val)) if val is not None else ""
|
|
66
|
+
html_parts.append(f" <td>{escape(val_str)}</td>")
|
|
67
|
+
html_parts.append(" </tr>")
|
|
68
|
+
html_parts.append(" </tbody>")
|
|
69
|
+
html_parts.append("</table>")
|
|
70
|
+
html_parts.append("</body>")
|
|
71
|
+
html_parts.append("</html>")
|
|
72
|
+
|
|
73
|
+
content = "\n".join(html_parts)
|
|
74
|
+
with NamedTemporaryFile(delete=False, mode="w", encoding="utf-8", suffix=".html") as tmp:
|
|
75
|
+
tmp.write(content)
|
|
76
|
+
return Path(tmp.name)
|
|
77
|
+
|
|
78
|
+
if isinstance(dataset, dict) and "rows" not in dataset:
|
|
79
|
+
entries = []
|
|
80
|
+
for table_name, table_data in dataset.items():
|
|
81
|
+
slug_name = str(table_name).lower().replace(" ", "_").replace("í", "i").replace("á", "a")
|
|
82
|
+
alias = f"{slug_name}.html"
|
|
83
|
+
tmp_path = dump_html_file(table_name, table_data)
|
|
84
|
+
entries.append(ZipFileEntry(zipfilealias=alias, osfilepath=tmp_path))
|
|
85
|
+
return self.write_zipfile(entries)
|
|
86
|
+
else:
|
|
87
|
+
tmp_path = dump_html_file("Report", dataset)
|
|
88
|
+
return self.write_zipfile([ZipFileEntry(zipfilealias=self.default_filename, osfilepath=tmp_path)])
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from tempfile import NamedTemporaryFile
|
|
5
|
+
|
|
6
|
+
from flat_file_renderers.base import BaseRenderer, ZipFileEntry
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _serialize_item(item):
|
|
12
|
+
"""Duck-typed record -> plain dict/value conversion.
|
|
13
|
+
|
|
14
|
+
Recognizes Django-style models (an object exposing `_meta.concrete_fields`) without
|
|
15
|
+
importing Django, so the encoder works the same whether or not Django is installed. Any
|
|
16
|
+
other non-dict object exposing `__dict__` (dataclasses, plain objects) is serialized via
|
|
17
|
+
its instance attributes. Everything else (dicts, plain values) passes through unchanged -
|
|
18
|
+
including values `json.dump`'s `default=str` fallback will stringify on its own
|
|
19
|
+
(date/datetime/Decimal/UUID/etc. all have a sensible `__str__`).
|
|
20
|
+
"""
|
|
21
|
+
meta = getattr(item, "_meta", None)
|
|
22
|
+
concrete_fields = getattr(meta, "concrete_fields", None)
|
|
23
|
+
if concrete_fields is not None:
|
|
24
|
+
return {field.name: getattr(item, field.name) for field in concrete_fields}
|
|
25
|
+
if not isinstance(item, dict) and hasattr(item, "__dict__"):
|
|
26
|
+
return dict(vars(item))
|
|
27
|
+
return item
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class JsonRenderer(BaseRenderer):
|
|
31
|
+
"""
|
|
32
|
+
Renders data as JSON.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
_extension = "json"
|
|
36
|
+
|
|
37
|
+
def render(self, context: dict[str, any], *args, **kwargs) -> Path:
|
|
38
|
+
"""Renders the data as JSON and writes it into a temporary zip file on disk.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
Path: Path to the compressed (zip) file containing the .json file(s).
|
|
42
|
+
"""
|
|
43
|
+
if "dataset" not in context:
|
|
44
|
+
raise ValueError("context must contain a 'dataset' key")
|
|
45
|
+
|
|
46
|
+
dataset = context.get("dataset")
|
|
47
|
+
|
|
48
|
+
def dump_json_file(data) -> Path:
|
|
49
|
+
if isinstance(data, list):
|
|
50
|
+
serial_data = [_serialize_item(item) for item in data]
|
|
51
|
+
elif hasattr(data, "model") and hasattr(data, "__iter__"):
|
|
52
|
+
serial_data = [_serialize_item(item) for item in data]
|
|
53
|
+
elif isinstance(data, dict):
|
|
54
|
+
serial_data = {
|
|
55
|
+
k: [_serialize_item(i) for i in v] if isinstance(v, list) else _serialize_item(v)
|
|
56
|
+
for k, v in data.items()
|
|
57
|
+
}
|
|
58
|
+
else:
|
|
59
|
+
serial_data = _serialize_item(data)
|
|
60
|
+
|
|
61
|
+
with NamedTemporaryFile(delete=False, mode="w", encoding="utf-8", suffix=".json") as tmp:
|
|
62
|
+
json.dump(serial_data, tmp, ensure_ascii=False, default=str, indent=2)
|
|
63
|
+
return Path(tmp.name)
|
|
64
|
+
|
|
65
|
+
if isinstance(dataset, dict) and "rows" not in dataset:
|
|
66
|
+
entries = []
|
|
67
|
+
for table_name, table_data in dataset.items():
|
|
68
|
+
slug_name = str(table_name).lower().replace(" ", "_").replace("í", "i").replace("á", "a")
|
|
69
|
+
alias = f"{slug_name}.json"
|
|
70
|
+
tmp_path = dump_json_file(table_data)
|
|
71
|
+
entries.append(ZipFileEntry(zipfilealias=alias, osfilepath=tmp_path))
|
|
72
|
+
return self.write_zipfile(entries)
|
|
73
|
+
else:
|
|
74
|
+
tmp_path = dump_json_file(dataset)
|
|
75
|
+
return self.write_zipfile([ZipFileEntry(zipfilealias=self.default_filename, osfilepath=tmp_path)])
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from tempfile import NamedTemporaryFile
|
|
4
|
+
|
|
5
|
+
import pyarrow as pa
|
|
6
|
+
import pyarrow.parquet as pq
|
|
7
|
+
|
|
8
|
+
from flat_file_renderers.base import BaseRenderer, ZipFileEntry
|
|
9
|
+
from flat_file_renderers.flat_dict_list_helper import FlatDictListHelper
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ParquetRenderer(BaseRenderer):
|
|
15
|
+
"""
|
|
16
|
+
Renders data as Apache Parquet (.parquet). Requires the ``parquet`` extra (pyarrow):
|
|
17
|
+
``pip install flat-file-renderers[parquet]``.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
_extension = "parquet"
|
|
21
|
+
|
|
22
|
+
def render(self, context: dict[str, any], *args, **kwargs) -> Path:
|
|
23
|
+
"""Renders the data as Apache Parquet and writes it into a temporary zip file on disk.
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
Path: Path to the compressed (zip) file containing the .parquet file(s).
|
|
27
|
+
"""
|
|
28
|
+
if "dataset" not in context:
|
|
29
|
+
raise ValueError("context must contain a 'dataset' key")
|
|
30
|
+
|
|
31
|
+
dataset = context.get("dataset")
|
|
32
|
+
|
|
33
|
+
def dump_parquet_file(sub_dataset: any) -> Path:
|
|
34
|
+
rows, cols = FlatDictListHelper.flatten_dataset({"dataset": sub_dataset})
|
|
35
|
+
|
|
36
|
+
# `rows` always comes from FlatDictListHelper.flatten_dataset(), which always
|
|
37
|
+
# produces dicts - so every row here is a dict.
|
|
38
|
+
cleaned_rows = []
|
|
39
|
+
for row in rows:
|
|
40
|
+
cleaned_row = {}
|
|
41
|
+
for col in cols:
|
|
42
|
+
val = row.get(col)
|
|
43
|
+
if isinstance(val, (list, dict)):
|
|
44
|
+
cleaned_row[col] = str(val)
|
|
45
|
+
else:
|
|
46
|
+
cleaned_row[col] = val
|
|
47
|
+
cleaned_rows.append(cleaned_row)
|
|
48
|
+
|
|
49
|
+
# `pa.Table.from_batches([])` requires an explicit (possibly empty) schema - without
|
|
50
|
+
# one it raises "Must pass schema, or at least one RecordBatch".
|
|
51
|
+
table = (
|
|
52
|
+
pa.Table.from_pylist(cleaned_rows) if cleaned_rows else pa.Table.from_batches([], schema=pa.schema([]))
|
|
53
|
+
)
|
|
54
|
+
with NamedTemporaryFile(delete=False, suffix=".parquet") as tmp:
|
|
55
|
+
pq.write_table(table, tmp.name)
|
|
56
|
+
return Path(tmp.name)
|
|
57
|
+
|
|
58
|
+
if isinstance(dataset, dict) and "rows" not in dataset:
|
|
59
|
+
entries = []
|
|
60
|
+
for table_name, table_data in dataset.items():
|
|
61
|
+
slug_name = str(table_name).lower().replace(" ", "_").replace("í", "i").replace("á", "a")
|
|
62
|
+
alias = f"{slug_name}.parquet"
|
|
63
|
+
tmp_path = dump_parquet_file(table_data)
|
|
64
|
+
entries.append(ZipFileEntry(zipfilealias=alias, osfilepath=tmp_path))
|
|
65
|
+
return self.write_zipfile(entries)
|
|
66
|
+
else:
|
|
67
|
+
tmp_path = dump_parquet_file(dataset)
|
|
68
|
+
return self.write_zipfile([ZipFileEntry(zipfilealias=self.default_filename, osfilepath=tmp_path)])
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from csv import DictWriter
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from tempfile import NamedTemporaryFile
|
|
5
|
+
|
|
6
|
+
from flat_file_renderers.base import BaseRenderer, ZipFileEntry
|
|
7
|
+
from flat_file_renderers.flat_dict_list_helper import FlatDictListHelper
|
|
8
|
+
from flat_file_renderers.text import replace_crlf
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class FlatDelimitedRenderer(BaseRenderer):
|
|
14
|
+
"""
|
|
15
|
+
Renders data as delimiter-separated values (CSV or TSV).
|
|
16
|
+
|
|
17
|
+
This base class exports tabular data into delimiter-separated files, such as CSV (comma)
|
|
18
|
+
or TSV (tab).
|
|
19
|
+
|
|
20
|
+
The context must contain the 'dataset' key, which can be:
|
|
21
|
+
- A QuerySet-like iterable: columns are inferred from the record fields.
|
|
22
|
+
- A dict: must contain 'rows' (list of dicts) and 'cols' (list of column names).
|
|
23
|
+
- A list: list of dicts, columns inferred from the first item's keys.
|
|
24
|
+
|
|
25
|
+
Usage examples:
|
|
26
|
+
renderer = CsvRenderer({'dataset': {'cols': ["Name", "Age"], 'rows': [{"Name": "Alice", "Age": "30"}]}})
|
|
27
|
+
csv_file = renderer.render({'dataset': ...})
|
|
28
|
+
with open("data.csv", "wb") as f:
|
|
29
|
+
f.write(csv_file.read_bytes())
|
|
30
|
+
|
|
31
|
+
renderer = CsvRenderer({})
|
|
32
|
+
csv_file = renderer.render({'dataset': [{"Name": "Alice", "Age": "30"}, {"Name": "Bob", "Age": "25"}]})
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def delimiter(self):
|
|
37
|
+
"""
|
|
38
|
+
Returns the delimiter used to separate values.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
str: Delimiter (comma by default).
|
|
42
|
+
"""
|
|
43
|
+
return getattr(self, "_delimiter", ",")
|
|
44
|
+
|
|
45
|
+
def render(self, context: dict[str, any], *args, **kwargs) -> Path:
|
|
46
|
+
"""
|
|
47
|
+
Renders the data as delimiter-separated values and writes it into a temporary zip file
|
|
48
|
+
on disk.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
context (dict): Dict holding the data to render. Must contain the 'dataset' key.
|
|
52
|
+
*args: Extra positional arguments.
|
|
53
|
+
**kwargs: Extra keyword arguments.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
Path: Path to the compressed (zip) file containing the delimited file(s).
|
|
57
|
+
"""
|
|
58
|
+
dataset = context.get("dataset")
|
|
59
|
+
entries = []
|
|
60
|
+
|
|
61
|
+
if isinstance(dataset, dict) and "rows" not in dataset:
|
|
62
|
+
for table_name, table_data in dataset.items():
|
|
63
|
+
slug_name = str(table_name).lower().replace(" ", "_").replace("í", "i").replace("á", "a")
|
|
64
|
+
alias = f"{slug_name}.{self.extension}"
|
|
65
|
+
tmp = NamedTemporaryFile(delete=False, mode="w", encoding="utf-8", newline="")
|
|
66
|
+
self.__write_dsv({"dataset": table_data}, tmp)
|
|
67
|
+
tmp.close()
|
|
68
|
+
entries.append(ZipFileEntry(zipfilealias=alias, osfilepath=Path(tmp.name)))
|
|
69
|
+
return self.write_zipfile(entries)
|
|
70
|
+
else:
|
|
71
|
+
with NamedTemporaryFile(delete=False, mode="w", encoding="utf-8", newline="") as csvfile:
|
|
72
|
+
logger.info("Rendering file %s with delimiter '%s'", self.default_filename, self.delimiter)
|
|
73
|
+
self.__write_dsv(context, csvfile)
|
|
74
|
+
logger.info("File %s rendered successfully.", self.default_filename)
|
|
75
|
+
csvfile_path = Path(csvfile.name)
|
|
76
|
+
# write_zipfile needs to read the file from disk: calling it while still inside the
|
|
77
|
+
# "with" block would zip the file before its write buffer was flushed/closed,
|
|
78
|
+
# producing an empty csv/tsv inside the zip.
|
|
79
|
+
return self.write_zipfile([ZipFileEntry(zipfilealias=self.default_filename, osfilepath=csvfile_path)])
|
|
80
|
+
|
|
81
|
+
def __write_dsv(self, context: dict[str, any], csvfile: NamedTemporaryFile):
|
|
82
|
+
rows, cols = FlatDictListHelper.flatten_dataset(context)
|
|
83
|
+
writer = DictWriter(csvfile, fieldnames=cols, delimiter=self.delimiter)
|
|
84
|
+
writer.writeheader()
|
|
85
|
+
line_num = 1
|
|
86
|
+
for row in rows:
|
|
87
|
+
if line_num % 500 == 1 and line_num > 1:
|
|
88
|
+
logger.info("Progress: %d rows written to %s", line_num - 1, self.default_filename)
|
|
89
|
+
writer.writerow({col: replace_crlf(row.get(col) or "") for col in cols})
|
|
90
|
+
line_num += 1
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class CsvRenderer(FlatDelimitedRenderer):
|
|
94
|
+
"""
|
|
95
|
+
Renders data as CSV (Comma-Separated Values).
|
|
96
|
+
|
|
97
|
+
Uses comma as delimiter and '.csv' extension.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
_delimiter = ","
|
|
101
|
+
_extension = "csv"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class TsvRenderer(FlatDelimitedRenderer):
|
|
105
|
+
"""
|
|
106
|
+
Renders data as TSV (Tab-Separated Values).
|
|
107
|
+
|
|
108
|
+
Uses tab as delimiter and '.tsv' extension.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
_delimiter = "\t"
|
|
112
|
+
_extension = "tsv"
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
def replace_crlf(val: str) -> str:
|
|
2
|
+
"""Replaces embedded newline/carriage-return characters with a visible ``\\n``/``\\r`` marker.
|
|
3
|
+
|
|
4
|
+
Useful before writing a value into a single tabular cell (CSV/TSV/XLSX/HTML), where a raw
|
|
5
|
+
embedded line break would otherwise corrupt the row layout.
|
|
6
|
+
"""
|
|
7
|
+
return val.replace("\n", r"\\n").replace("\r", r"\\r") if isinstance(val, str) else val
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from tempfile import NamedTemporaryFile
|
|
4
|
+
|
|
5
|
+
from openpyxl import Workbook
|
|
6
|
+
|
|
7
|
+
from flat_file_renderers.base import BaseRenderer, ZipFileEntry
|
|
8
|
+
from flat_file_renderers.flat_dict_list_helper import FlatDictListHelper
|
|
9
|
+
from flat_file_renderers.text import replace_crlf
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class XlsxRenderer(BaseRenderer):
|
|
15
|
+
"""
|
|
16
|
+
Renders data as XLSX (Excel). Requires the ``xlsx`` extra (openpyxl):
|
|
17
|
+
``pip install flat-file-renderers[xlsx]``.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
_extension = "xlsx"
|
|
21
|
+
|
|
22
|
+
def render(self, context: dict[str, any], *args, **kwargs) -> Path:
|
|
23
|
+
"""Renders the data as XLSX and writes it into a temporary zip file on disk.
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
Path: Path to the compressed (zip) file containing the .xlsx file.
|
|
27
|
+
"""
|
|
28
|
+
dataset = context.get("dataset")
|
|
29
|
+
wb = Workbook()
|
|
30
|
+
|
|
31
|
+
if isinstance(dataset, dict) and "rows" not in dataset:
|
|
32
|
+
first = True
|
|
33
|
+
for sheet_name, sheet_data in dataset.items():
|
|
34
|
+
if first:
|
|
35
|
+
ws = wb.active
|
|
36
|
+
ws.title = str(sheet_name)[:31]
|
|
37
|
+
first = False
|
|
38
|
+
else:
|
|
39
|
+
ws = wb.create_sheet(title=str(sheet_name)[:31])
|
|
40
|
+
|
|
41
|
+
rows, cols = FlatDictListHelper.flatten_dataset({"dataset": sheet_data})
|
|
42
|
+
ws.append(cols)
|
|
43
|
+
|
|
44
|
+
# `rows` always comes from FlatDictListHelper.flatten_dataset(), which always
|
|
45
|
+
# produces dicts - so every row here is a dict.
|
|
46
|
+
for row in rows:
|
|
47
|
+
ws.append(
|
|
48
|
+
[replace_crlf(str(row.get(col, "") or "")) if row.get(col) is not None else "" for col in cols]
|
|
49
|
+
)
|
|
50
|
+
else:
|
|
51
|
+
ws = wb.active
|
|
52
|
+
rows, cols = FlatDictListHelper.flatten_dataset(context)
|
|
53
|
+
ws.append(cols)
|
|
54
|
+
|
|
55
|
+
for row in rows:
|
|
56
|
+
ws.append(
|
|
57
|
+
[replace_crlf(str(row.get(col, "") or "")) if row.get(col) is not None else "" for col in cols]
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
with NamedTemporaryFile(delete=False, suffix=".xlsx") as tmp:
|
|
61
|
+
wb.save(tmp.name)
|
|
62
|
+
tmp_path = Path(tmp.name)
|
|
63
|
+
|
|
64
|
+
return self.write_zipfile([ZipFileEntry(zipfilealias=self.default_filename, osfilepath=tmp_path)])
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: flat-file-renderers
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Render tabular/nested datasets (list/dict/QuerySet-like) into xlsx, csv, tsv, json, html or parquet files, zipped, with zero required dependencies
|
|
5
|
+
Author-email: Kelson da Costa Medeiros <kelsoncm@gmail.com>
|
|
6
|
+
License: # License
|
|
7
|
+
|
|
8
|
+
## The MIT License (MIT)
|
|
9
|
+
|
|
10
|
+
Copyright (c) 2026 python-by-kelsoncm
|
|
11
|
+
|
|
12
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
13
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
14
|
+
in the Software without restriction, including without limitation the rights
|
|
15
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
16
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
17
|
+
furnished to do so, subject to the following conditions:
|
|
18
|
+
|
|
19
|
+
The above copyright notice and this permission notice shall be included in all
|
|
20
|
+
copies or substantial portions of the Software.
|
|
21
|
+
|
|
22
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
23
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
24
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
25
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
26
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
27
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
28
|
+
SOFTWARE.
|
|
29
|
+
|
|
30
|
+
Project-URL: Homepage, https://github.com/python-by-kelsoncm/python-flat-file-renderers
|
|
31
|
+
Project-URL: Bug Tracker, https://github.com/python-by-kelsoncm/python-flat-file-renderers/issues
|
|
32
|
+
Project-URL: Download, https://github.com/python-by-kelsoncm/python-flat-file-renderers/releases/
|
|
33
|
+
Project-URL: Docs, https://python-by-kelsoncm.github.io/python-flat-file-renderers/
|
|
34
|
+
Keywords: export,xlsx,csv,tsv,json,html,parquet,renderer,flatten
|
|
35
|
+
Classifier: Development Status :: 4 - Beta
|
|
36
|
+
Classifier: Intended Audience :: Developers
|
|
37
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
38
|
+
Classifier: Operating System :: OS Independent
|
|
39
|
+
Classifier: Programming Language :: Python :: 3
|
|
40
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
41
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
42
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
43
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
44
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
45
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
46
|
+
Requires-Python: >=3.10
|
|
47
|
+
Description-Content-Type: text/markdown
|
|
48
|
+
License-File: LICENSE.md
|
|
49
|
+
License-File: AUTHORS.md
|
|
50
|
+
Provides-Extra: xlsx
|
|
51
|
+
Requires-Dist: openpyxl>=3.1; extra == "xlsx"
|
|
52
|
+
Provides-Extra: parquet
|
|
53
|
+
Requires-Dist: pyarrow>=15; extra == "parquet"
|
|
54
|
+
Provides-Extra: all
|
|
55
|
+
Requires-Dist: openpyxl>=3.1; extra == "all"
|
|
56
|
+
Requires-Dist: pyarrow>=15; extra == "all"
|
|
57
|
+
Provides-Extra: dev
|
|
58
|
+
Requires-Dist: openpyxl>=3.1; extra == "dev"
|
|
59
|
+
Requires-Dist: pyarrow>=15; extra == "dev"
|
|
60
|
+
Requires-Dist: pre-commit>=4.6.0; extra == "dev"
|
|
61
|
+
Requires-Dist: black>=26.3.1; extra == "dev"
|
|
62
|
+
Requires-Dist: ruff>=0.15.11; extra == "dev"
|
|
63
|
+
Requires-Dist: doc8>=2.0.0; extra == "dev"
|
|
64
|
+
Requires-Dist: pytest>=9.0.3; extra == "dev"
|
|
65
|
+
Requires-Dist: pytest-cov>=7.1.0; extra == "dev"
|
|
66
|
+
Requires-Dist: pytest-coverage-gate>=1.0.3; extra == "dev"
|
|
67
|
+
Dynamic: license-file
|
|
68
|
+
|
|
69
|
+
# flat-file-renderers
|
|
70
|
+
|
|
71
|
+
[](https://opensource.org/licenses/MIT)
|
|
72
|
+
[](https://pypi.org/project/flat-file-renderers/)
|
|
73
|
+
[](https://github.com/python-by-kelsoncm/python-flat-file-renderers/actions/workflows/qa.yml)
|
|
74
|
+
[](https://codecov.io/gh/python-by-kelsoncm/python-flat-file-renderers)
|
|
75
|
+
[](https://github.com/python-by-kelsoncm/python-flat-file-renderers/actions/workflows/publish.yml)
|
|
76
|
+
[](https://python-by-kelsoncm.github.io/python-flat-file-renderers/)
|
|
77
|
+
[](https://github.com/pre-commit/pre-commit)
|
|
78
|
+
|
|
79
|
+
Render tabular/nested datasets - a `list` of dicts, a `dict` of `{"rows": [...], "cols": [...]}`,
|
|
80
|
+
a QuerySet-like iterable, or record/model objects - into **xlsx, csv, tsv, json, html or parquet**
|
|
81
|
+
files, zipped. Nested lists of dicts are flattened automatically into dot+index-notation columns
|
|
82
|
+
(`contacts.1.phone`, `contacts.2.phone`, ...), and a `dict[str, dataset]` renders one file per key
|
|
83
|
+
into the same zip (one sheet per key for xlsx, one file per key for the others).
|
|
84
|
+
|
|
85
|
+
No required dependencies for csv/tsv/json/html. `xlsx` and `parquet` are optional extras.
|
|
86
|
+
|
|
87
|
+
## Installation
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
pip install flat-file-renderers # csv, tsv, json, html - zero extra dependencies
|
|
91
|
+
pip install flat-file-renderers[xlsx] # + openpyxl, for XlsxRenderer
|
|
92
|
+
pip install flat-file-renderers[parquet] # + pyarrow, for ParquetRenderer
|
|
93
|
+
pip install flat-file-renderers[all] # everything
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Quick start
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
from flat_file_renderers import CsvRenderer
|
|
100
|
+
|
|
101
|
+
renderer = CsvRenderer({})
|
|
102
|
+
zip_path = renderer.render({"dataset": [{"name": "Alice", "age": 30}, {"name": "Bob", "age": 25}]})
|
|
103
|
+
# zip_path -> Path to a .zip containing one .csv
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
from flat_file_renderers.xlsx import XlsxRenderer # requires the "xlsx" extra
|
|
108
|
+
|
|
109
|
+
renderer = XlsxRenderer({})
|
|
110
|
+
zip_path = renderer.render({"dataset": {"Students": [{"name": "Alice"}], "Courses": [{"title": "Python"}]}})
|
|
111
|
+
# zip_path -> Path to a .zip containing one .xlsx with two sheets
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Every renderer shares the same interface (`BaseRenderer.render(context) -> Path`, where
|
|
115
|
+
`context["dataset"]` holds the data), so switching output formats is a one-line change:
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
from flat_file_renderers import CsvRenderer, TsvRenderer, JsonRenderer, HtmlRenderer
|
|
119
|
+
from flat_file_renderers.xlsx import XlsxRenderer
|
|
120
|
+
from flat_file_renderers.parquet import ParquetRenderer
|
|
121
|
+
|
|
122
|
+
for Renderer in (CsvRenderer, TsvRenderer, JsonRenderer, HtmlRenderer, XlsxRenderer, ParquetRenderer):
|
|
123
|
+
zip_path = Renderer({}).render({"dataset": rows})
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## Modules
|
|
127
|
+
|
|
128
|
+
* `flat_file_renderers.base` - `BaseRenderer`, `ZipFileEntry`
|
|
129
|
+
* `flat_file_renderers.flat_dict_list_helper` - `FlatDictListHelper`, the flattening engine shared
|
|
130
|
+
by every renderer
|
|
131
|
+
* `flat_file_renderers.separated_value` - `CsvRenderer`, `TsvRenderer`
|
|
132
|
+
* `flat_file_renderers.json` - `JsonRenderer`
|
|
133
|
+
* `flat_file_renderers.html` - `HtmlRenderer`
|
|
134
|
+
* `flat_file_renderers.xlsx` - `XlsxRenderer` (extra: `xlsx`)
|
|
135
|
+
* `flat_file_renderers.parquet` - `ParquetRenderer` (extra: `parquet`)
|
|
136
|
+
|
|
137
|
+
See the [documentation](https://python-by-kelsoncm.github.io/python-flat-file-renderers/) for
|
|
138
|
+
details and more examples.
|
|
139
|
+
|
|
140
|
+
## Security
|
|
141
|
+
|
|
142
|
+
Please report vulnerabilities according to [SECURITY.md](SECURITY.md).
|
|
143
|
+
|
|
144
|
+
## Author
|
|
145
|
+
|
|
146
|
+
Kelson da Costa Medeiros <kelsoncm@gmail.com>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
flat_file_renderers/__init__.py,sha256=jTonALV9bV3WLdASnkKp5SKTspffVEMKjcW0Ycm5IOg,1243
|
|
2
|
+
flat_file_renderers/base.py,sha256=BwLjZADhwLAb--GUm7XLm02Ntq4D23O0RmE76Gyoiik,3717
|
|
3
|
+
flat_file_renderers/flat_dict_list_helper.py,sha256=rwzYjvRfpwGjOjdIiGUF6Xruxrx4QEJ6CG_Z8ViVZlA,8233
|
|
4
|
+
flat_file_renderers/html.py,sha256=6UqSflI3s19q2vjJlS6ocIrfGTAj1d59EMBQ7NrXE4g,3877
|
|
5
|
+
flat_file_renderers/json.py,sha256=cn3r0CnrCDT0Kz15OLhC8VRPFKCkTQhHdAnoEwT5fqc,3156
|
|
6
|
+
flat_file_renderers/parquet.py,sha256=956MgarUDkC6nCGg5clscB6rD96wvUow7NnD81MTWjs,2810
|
|
7
|
+
flat_file_renderers/separated_value.py,sha256=P2_uIhdg8bx6OQ8EnIyWj336AsOpfihZjwPL-mUwRLs,4463
|
|
8
|
+
flat_file_renderers/text.py,sha256=keouP5ByikDBr-o1QQcEUEFwd0PTgHBUbWsL1rqQgUs,393
|
|
9
|
+
flat_file_renderers/xlsx.py,sha256=JVjXy8tmsoOw9PaINbMOXHZKqSI_Y1QqGNIYu4X_NqE,2317
|
|
10
|
+
flat_file_renderers-0.1.0.dist-info/licenses/AUTHORS.md,sha256=qk2yCTi5iFKzq5whkvvZ-Wq-Mj47xcoOJGJkbu_RnI0,84
|
|
11
|
+
flat_file_renderers-0.1.0.dist-info/licenses/LICENSE.md,sha256=mYRSY4lBNy3XDQwvexDjx4MsqTkyIosr8nZF4aqQRIE,1099
|
|
12
|
+
flat_file_renderers-0.1.0.dist-info/METADATA,sha256=tu6g2zOXjj4Eob6lBHG9GXBQwEMxh6nZ_5ZSJTsxdQw,7275
|
|
13
|
+
flat_file_renderers-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
14
|
+
flat_file_renderers-0.1.0.dist-info/top_level.txt,sha256=rBvZ2SJJvUO7TnXRPlSy0M3bMQYSl6f5mU7wwoEcesY,20
|
|
15
|
+
flat_file_renderers-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# License
|
|
2
|
+
|
|
3
|
+
## The MIT License (MIT)
|
|
4
|
+
|
|
5
|
+
Copyright (c) 2026 python-by-kelsoncm
|
|
6
|
+
|
|
7
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
8
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
9
|
+
in the Software without restriction, including without limitation the rights
|
|
10
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
11
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
12
|
+
furnished to do so, subject to the following conditions:
|
|
13
|
+
|
|
14
|
+
The above copyright notice and this permission notice shall be included in all
|
|
15
|
+
copies or substantial portions of the Software.
|
|
16
|
+
|
|
17
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
18
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
19
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
20
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
21
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
22
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
23
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
flat_file_renderers
|