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.
@@ -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
+ [![License](https://img.shields.io/badge/License-MIT-lemon.svg)](https://opensource.org/licenses/MIT)
72
+ [![Python](https://img.shields.io/pypi/pyversions/flat-file-renderers.svg)](https://pypi.org/project/flat-file-renderers/)
73
+ [![QA](https://github.com/python-by-kelsoncm/python-flat-file-renderers/actions/workflows/qa.yml/badge.svg)](https://github.com/python-by-kelsoncm/python-flat-file-renderers/actions/workflows/qa.yml)
74
+ [![Coverage](https://codecov.io/gh/python-by-kelsoncm/python-flat-file-renderers/branch/main/graph/badge.svg)](https://codecov.io/gh/python-by-kelsoncm/python-flat-file-renderers)
75
+ [![Publish](https://github.com/python-by-kelsoncm/python-flat-file-renderers/actions/workflows/publish.yml/badge.svg)](https://github.com/python-by-kelsoncm/python-flat-file-renderers/actions/workflows/publish.yml)
76
+ [![Docs](https://github.com/python-by-kelsoncm/python-flat-file-renderers/actions/workflows/docs.yml/badge.svg)](https://python-by-kelsoncm.github.io/python-flat-file-renderers/)
77
+ [![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit)](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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,3 @@
1
+ # Authors
2
+
3
+ - Kelson da Costa Medeiros <kelsoncm@gmail.com>, Tech Leader & Developer
@@ -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