flat-file-renderers 0.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.
Files changed (29) hide show
  1. flat_file_renderers-0.1.0/AUTHORS.md +3 -0
  2. flat_file_renderers-0.1.0/LICENSE.md +23 -0
  3. flat_file_renderers-0.1.0/PKG-INFO +146 -0
  4. flat_file_renderers-0.1.0/README.md +78 -0
  5. flat_file_renderers-0.1.0/flat_file_renderers/__init__.py +32 -0
  6. flat_file_renderers-0.1.0/flat_file_renderers/base.py +114 -0
  7. flat_file_renderers-0.1.0/flat_file_renderers/flat_dict_list_helper.py +205 -0
  8. flat_file_renderers-0.1.0/flat_file_renderers/html.py +88 -0
  9. flat_file_renderers-0.1.0/flat_file_renderers/json.py +75 -0
  10. flat_file_renderers-0.1.0/flat_file_renderers/parquet.py +68 -0
  11. flat_file_renderers-0.1.0/flat_file_renderers/separated_value.py +112 -0
  12. flat_file_renderers-0.1.0/flat_file_renderers/text.py +7 -0
  13. flat_file_renderers-0.1.0/flat_file_renderers/xlsx.py +64 -0
  14. flat_file_renderers-0.1.0/flat_file_renderers.egg-info/PKG-INFO +146 -0
  15. flat_file_renderers-0.1.0/flat_file_renderers.egg-info/SOURCES.txt +27 -0
  16. flat_file_renderers-0.1.0/flat_file_renderers.egg-info/dependency_links.txt +1 -0
  17. flat_file_renderers-0.1.0/flat_file_renderers.egg-info/requires.txt +21 -0
  18. flat_file_renderers-0.1.0/flat_file_renderers.egg-info/top_level.txt +1 -0
  19. flat_file_renderers-0.1.0/pyproject.toml +71 -0
  20. flat_file_renderers-0.1.0/setup.cfg +4 -0
  21. flat_file_renderers-0.1.0/tests/test_base.py +53 -0
  22. flat_file_renderers-0.1.0/tests/test_flat_dict_list_helper.py +149 -0
  23. flat_file_renderers-0.1.0/tests/test_html.py +51 -0
  24. flat_file_renderers-0.1.0/tests/test_init.py +19 -0
  25. flat_file_renderers-0.1.0/tests/test_json.py +158 -0
  26. flat_file_renderers-0.1.0/tests/test_parquet.py +65 -0
  27. flat_file_renderers-0.1.0/tests/test_separated_value.py +74 -0
  28. flat_file_renderers-0.1.0/tests/test_text.py +12 -0
  29. flat_file_renderers-0.1.0/tests/test_xlsx.py +58 -0
@@ -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,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,78 @@
1
+ # flat-file-renderers
2
+
3
+ [![License](https://img.shields.io/badge/License-MIT-lemon.svg)](https://opensource.org/licenses/MIT)
4
+ [![Python](https://img.shields.io/pypi/pyversions/flat-file-renderers.svg)](https://pypi.org/project/flat-file-renderers/)
5
+ [![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)
6
+ [![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)
7
+ [![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)
8
+ [![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/)
9
+ [![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit)](https://github.com/pre-commit/pre-commit)
10
+
11
+ Render tabular/nested datasets - a `list` of dicts, a `dict` of `{"rows": [...], "cols": [...]}`,
12
+ a QuerySet-like iterable, or record/model objects - into **xlsx, csv, tsv, json, html or parquet**
13
+ files, zipped. Nested lists of dicts are flattened automatically into dot+index-notation columns
14
+ (`contacts.1.phone`, `contacts.2.phone`, ...), and a `dict[str, dataset]` renders one file per key
15
+ into the same zip (one sheet per key for xlsx, one file per key for the others).
16
+
17
+ No required dependencies for csv/tsv/json/html. `xlsx` and `parquet` are optional extras.
18
+
19
+ ## Installation
20
+
21
+ ```bash
22
+ pip install flat-file-renderers # csv, tsv, json, html - zero extra dependencies
23
+ pip install flat-file-renderers[xlsx] # + openpyxl, for XlsxRenderer
24
+ pip install flat-file-renderers[parquet] # + pyarrow, for ParquetRenderer
25
+ pip install flat-file-renderers[all] # everything
26
+ ```
27
+
28
+ ## Quick start
29
+
30
+ ```python
31
+ from flat_file_renderers import CsvRenderer
32
+
33
+ renderer = CsvRenderer({})
34
+ zip_path = renderer.render({"dataset": [{"name": "Alice", "age": 30}, {"name": "Bob", "age": 25}]})
35
+ # zip_path -> Path to a .zip containing one .csv
36
+ ```
37
+
38
+ ```python
39
+ from flat_file_renderers.xlsx import XlsxRenderer # requires the "xlsx" extra
40
+
41
+ renderer = XlsxRenderer({})
42
+ zip_path = renderer.render({"dataset": {"Students": [{"name": "Alice"}], "Courses": [{"title": "Python"}]}})
43
+ # zip_path -> Path to a .zip containing one .xlsx with two sheets
44
+ ```
45
+
46
+ Every renderer shares the same interface (`BaseRenderer.render(context) -> Path`, where
47
+ `context["dataset"]` holds the data), so switching output formats is a one-line change:
48
+
49
+ ```python
50
+ from flat_file_renderers import CsvRenderer, TsvRenderer, JsonRenderer, HtmlRenderer
51
+ from flat_file_renderers.xlsx import XlsxRenderer
52
+ from flat_file_renderers.parquet import ParquetRenderer
53
+
54
+ for Renderer in (CsvRenderer, TsvRenderer, JsonRenderer, HtmlRenderer, XlsxRenderer, ParquetRenderer):
55
+ zip_path = Renderer({}).render({"dataset": rows})
56
+ ```
57
+
58
+ ## Modules
59
+
60
+ * `flat_file_renderers.base` - `BaseRenderer`, `ZipFileEntry`
61
+ * `flat_file_renderers.flat_dict_list_helper` - `FlatDictListHelper`, the flattening engine shared
62
+ by every renderer
63
+ * `flat_file_renderers.separated_value` - `CsvRenderer`, `TsvRenderer`
64
+ * `flat_file_renderers.json` - `JsonRenderer`
65
+ * `flat_file_renderers.html` - `HtmlRenderer`
66
+ * `flat_file_renderers.xlsx` - `XlsxRenderer` (extra: `xlsx`)
67
+ * `flat_file_renderers.parquet` - `ParquetRenderer` (extra: `parquet`)
68
+
69
+ See the [documentation](https://python-by-kelsoncm.github.io/python-flat-file-renderers/) for
70
+ details and more examples.
71
+
72
+ ## Security
73
+
74
+ Please report vulnerabilities according to [SECURITY.md](SECURITY.md).
75
+
76
+ ## Author
77
+
78
+ Kelson da Costa Medeiros <kelsoncm@gmail.com>
@@ -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