pqax 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.
pqax-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SQLBI
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
pqax-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,190 @@
1
+ Metadata-Version: 2.4
2
+ Name: pqax
3
+ Version: 0.1.0
4
+ Summary: Read, validate, and write PQAX files: the footprint of Parquet tables, for analysis without access to the data.
5
+ Author: SQLBI
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://pqax.org
8
+ Project-URL: Repository, https://github.com/pqax-org/pqax
9
+ Keywords: pqax,parquet,delta,lakehouse,footprint,vertipaq
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Topic :: Database
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.10
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE
24
+ Provides-Extra: pandas
25
+ Requires-Dist: pandas; extra == "pandas"
26
+ Provides-Extra: polars
27
+ Requires-Dist: polars>=1.0; extra == "polars"
28
+ Provides-Extra: dev
29
+ Requires-Dist: pytest; extra == "dev"
30
+ Requires-Dist: jsonschema>=4.18; extra == "dev"
31
+ Dynamic: license-file
32
+
33
+ # pqax for Python
34
+
35
+ `pqax` reads, validates, and writes PQAX files: the footprint of Parquet tables, as a zip of JSON tables. The format is defined by the [serialization](https://github.com/pqax-org/pqax/blob/main/docs/serialization.md) and the [object model](https://github.com/pqax-org/pqax/blob/main/docs/object-model.md), and the validation rules by the [conformance kit](https://github.com/pqax-org/pqax/blob/main/conformance/rules.md).
36
+
37
+ | | |
38
+ |---|---|
39
+ | Version | 0.1.0 |
40
+ | Format | Reads every version 0.x. Writes version 0.1. |
41
+ | Python | 3.10 or later |
42
+ | Dependencies | None. `to_dataframe` uses pandas, or polars 1.0 or later, when one is installed. |
43
+
44
+ ## Install
45
+
46
+ ```bash
47
+ pip install pqax # the package and the pqax command
48
+ pip install "pqax[pandas]" # with pandas, for to_dataframe
49
+ pip install "pqax[polars]" # with polars, for to_dataframe
50
+ ```
51
+
52
+ From the root of the repository:
53
+
54
+ ```bash
55
+ pip install -e src/python # the package and the pqax command
56
+ pip install -e "src/python[pandas]" # with pandas, for to_dataframe
57
+ pip install -e "src/python[dev]" # with pytest and jsonschema, for the tests
58
+ ```
59
+
60
+ Python versions: 3.10 or later. The tests run on 3.10 and 3.14, each without extras and with both pandas and polars. The package has no dependency: the `pandas` and `polars` extras only install the library that `to_dataframe` uses.
61
+
62
+ ## Read a file
63
+
64
+ ```python
65
+ import pqax
66
+
67
+ with pqax.open("footprint.pqax") as doc:
68
+ print(doc.manifest["formatVersion"], doc.part_names)
69
+ tables = doc.summary["tables"] # read when the file opens
70
+ for table_id, size in zip(tables["id"], tables.get("fileSize", [])):
71
+ print(table_id, size)
72
+ chunks = doc.table("columnChunks") # read now, from all its parts, and kept
73
+ print(doc.row_count("columnChunks"), sum(chunks["compressedSize"]))
74
+ for row in doc.rows("files"): # one dictionary for each row
75
+ print(row["id"], row["fileSize"])
76
+ ```
77
+
78
+ A table is a dictionary of fields, and a field is a list with one value for each row, as in the file. The keys keep the names of the file, such as `fileCount`. A value that the file does not have is `None`.
79
+
80
+ - Opening a file reads the zip directory, `manifest.json`, and `summary.json`. A detail part is read the first time a table that it holds is asked for.
81
+ - `doc.table(name)` concatenates the parts of a table in the order of the manifest. A field that a part leaves out is `None` in the rows of that part. A table that the file does not have is `{}`.
82
+ - A field that has no value in any row is not in the dictionary: use `table.get("field")`.
83
+ - A value whose JSON type is not the type of its field, such as a string where the model says integer, reads as `None`. `doc.raw(name)` returns the table as parsed, with that value and with the members of a table that are not arrays.
84
+ - Fields and tables that this version does not define are returned like the others. Members of the manifest that it does not define stay in `doc.manifest`.
85
+
86
+ A PQAX file embedded in a VPAX file:
87
+
88
+ ```python
89
+ with pqax.open_embedded("model.vpax") as doc: # the part Footprint.pqax, stored or deflated
90
+ print(doc.embedded_part, doc.embedded_compression)
91
+ ```
92
+
93
+ The part is read into memory, and the VPAX file is closed before `open_embedded` returns.
94
+
95
+ ## Validate a file
96
+
97
+ ```python
98
+ result = pqax.validate("footprint.pqax") # or pqax.validate_embedded("model.vpax")
99
+ print(result.is_valid, result.checks)
100
+ for issue in result.issues:
101
+ print(issue.code, issue.message, issue.part, issue.table, issue.field)
102
+ ```
103
+
104
+ The validator reports every issue that it finds, with the codes of section 4.1 of the [contracts](https://github.com/pqax-org/pqax/blob/main/docs/contracts.md): `container.*`, `json.*`, `manifest.*`, `part.*`, `table.*`, `depth.*`, `totals.*`, and, for `validate_embedded`, `embedded.partMissing`. A rule that cannot be evaluated because an earlier one failed is skipped. A file that cannot be opened raises `OSError`. A file that is not a zip archive is an issue, `container.manifestMissing`, and so is a VPAX file given to `validate`.
105
+
106
+ ## Data frames
107
+
108
+ ```python
109
+ frame = pqax.to_dataframe(doc.table("columnChunks")) # pandas when installed, else polars
110
+ frame = pqax.to_dataframe(doc.summary["tables"], library="polars")
111
+ ```
112
+
113
+ With pandas, an integer or boolean column with `None` values gets a nullable type, so it does not turn into floats. Lists and objects stay as Python objects. Timestamps stay strings; `pqax.parse_timestamp` turns one into a `datetime`. The rows of `doc.rows(name)` give the same frame as `doc.table(name)`.
114
+
115
+ `to_dataframe` needs polars 1.0 or later, and raises `ImportError` with an earlier version, which loses the values of a column that mixes types.
116
+
117
+ ## Write a file
118
+
119
+ ```python
120
+ from datetime import datetime, timezone
121
+
122
+ builder = pqax.Builder({"collectorApp": "my-tool", "level": 2, "startedAt": datetime.now(timezone.utc)})
123
+ source = builder.add_source(kind="Storage folder")
124
+ table = builder.add_table(source=source, name="sales", format="Delta", detailDepth="Files")
125
+ file_id = builder.add_file(table=table, path="part-0001.parquet", fileSize=47820)
126
+ builder.update("tables", table, fileCount=1, fileSize=47820)
127
+ builder.write("footprint.pqax")
128
+ ```
129
+
130
+ | Method | What it does |
131
+ |---|---|
132
+ | `add_source(**fields)`, `add_table`, `add_column`, and one for each of the 21 tables | Adds a row. For a table with an `id`, it keeps a positive id that the row brings, assigns the next free id when the row has none (greater than every id so far in the table, so from 1), and returns the id. For a table without, it returns the position of the row, from 0. The plural names, such as `add_files`, are the same methods. |
133
+ | `add(table, **fields)` | Adds a row to any table, also one that this version does not define, with any field |
134
+ | `extend(table, columns, detail=None)` | Adds many rows from a columnar table. `detail` places a table that the format does not define. |
135
+ | `update(table, id_or_position, **fields)` | Sets fields of a row already added, such as totals that are known after the detail |
136
+ | `write(path_or_file)` | Writes the file. It raises `ValueError`, naming the table and the id, when a table has an id in more than one row. |
137
+ | `Builder.from_document(doc)` | A builder with every table of a document, to rewrite a file |
138
+
139
+ The fields are named as in the file. An `add_` method refuses a field that its table does not have, unless the name has a producer's prefix and an underscore, as in `acme_note`. A field set to `None` has no value. A `datetime` becomes a timestamp in UTC with three decimals; a naive `datetime` is taken as UTC.
140
+
141
+ `write` lays out the file as the serialization says: `manifest.json` first, then `summary.json`, always present, then one part for each detail table, named after it, as `files.json`. A table with more than `rows_per_part` rows, 250,000 by default and at most, continues in parts numbered from 1, as `columnChunks.1.json`. A field with no value in any row of a part is left out of that part, and so is a table with no rows. The JSON is compact UTF-8 with no byte order mark, the entries are deflated at level 9 by default (`compression_level`), and every entry has the same date, so the same footprint gives the same bytes. A path is written through a temporary file.
142
+
143
+ `from_document` keeps the ids, the collection, the obfuscation fields, the other members of the manifest, and the fields and tables that this version does not define. It loses what the reader does not keep: the entries that the manifest does not list, the members of a table that are not arrays, and the members of an element of `parts` other than `name` and `tables`. It writes format version 0.1, and its own part names.
144
+
145
+ ## Command line
146
+
147
+ ```bash
148
+ python -m pqax check footprint.pqax # exit code 0 when valid, 1 when not, 2 on a usage or I/O error
149
+ python -m pqax info footprint.pqax # format, version, collection, parts with their tables and rows
150
+ python -m pqax info model.vpax # a VPAX file: the PQAX file that it embeds as Footprint.pqax
151
+ ```
152
+
153
+ `check` prints one line for each issue, its code first, then a summary line. Both commands take `--part NAME` for another embedded part. The installed package also has a `pqax` command.
154
+
155
+ ## API
156
+
157
+ | Name | What it is |
158
+ |---|---|
159
+ | `open(path_or_file)` | A `PqaxDocument`. The source is a path, the bytes of a file, or a binary file object, which is left open. |
160
+ | `open_embedded(path_or_file, part_name="Footprint.pqax")` | A `PqaxDocument` of the PQAX file that a package holds as a part |
161
+ | `PqaxDocument` | `manifest`, `summary`, `row_count(name)`, `table(name)`, `rows(name)`, `raw(name, part=None)`, `table_names`, `part_names`, `format_version`, `collection`, `embedded_part`, `embedded_compression`, `close()`, and the `with` statement |
162
+ | `validate(path_or_file)`, `validate_embedded(path_or_file, part_name)` | A `ValidationResult`: `is_valid`, `checks`, `issues`, `codes` |
163
+ | `Issue` | `code`, `message`, `part`, `table`, `field` |
164
+ | `to_dataframe(table, library=None)` | A pandas or polars data frame |
165
+ | `Builder`, `BuilderOptions` | The writer |
166
+ | `format_timestamp(datetime)`, `parse_timestamp(text)` | Timestamps in the form of the format, `2026-10-05T08:15:30.000Z` |
167
+ | `TABLES`, `SUMMARY_TABLES`, `DETAIL_TABLES`, `FIELDS`, `REQUIRED`, `REFERENCES` | The names of the format |
168
+
169
+ Errors:
170
+
171
+ | Error | When |
172
+ |---|---|
173
+ | `ManifestMissingError` | The archive has no `manifest.json` |
174
+ | `FormatError` | `format` is not `pqax` |
175
+ | `UnknownVersionError` | The first number of `formatVersion` is not 0 |
176
+ | `JsonError` | A part that is read is not valid JSON |
177
+ | `PartMissingError` | A detail part that the manifest lists is not in the archive, when its table is read (`manifest.partMissing`), or the embedded part is not in the package (`embedded.partMissing`) |
178
+ | `ContainerError` | The file is not a zip archive, or an entry cannot be read: encrypted, compressed with another method, or corrupt |
179
+
180
+ All derive from `PqaxError`, whose `code` is the validation code that the error matches, when there is one. A reader refuses a file for the first four, when it opens it, and `open_embedded` also for a missing embedded part. Opening also fails when the manifest or the summary cannot be read from the zip, for example because the entry is encrypted. A `summary.json` that the manifest lists and the archive does not have gives empty summary tables, and `validate` reports it. A detail part that is broken fails only the read of its tables.
181
+
182
+ ## Limits
183
+
184
+ - The reader keeps a table in memory once it is read. A part of 250,000 column chunks takes about 0.15 s to parse.
185
+ - A part that holds more than 1 GB of JSON text is not read, and is reported or raised as such.
186
+ - The rows of a part are the length of its longest array, not what the manifest states, so that a file that states more rows than it holds cannot make the reader allocate them. In a valid file the two agree.
187
+ - The reader replaces a value of another JSON type with `None` only at the level of the field. An element of a list, or a member of a nested object, is returned as the file has it.
188
+ - `container.zip64` looks at the central directory and its locator, as the kit says. A ZIP64 field in a local header alone is not reported.
189
+ - No pandas or polars type is chosen for timestamps, lists, maps, or nested objects.
190
+ - On Python 3.10, `parse_timestamp` reads fewer of the other forms of ISO 8601, as `datetime.fromisoformat` does there: a fraction of seconds with other than three or six digits, a comma before the fraction, an offset without a colon, and the basic form, such as `20261005T081530Z`, raise `ValueError`. The form of the format reads on every version.
pqax-0.1.0/README.md ADDED
@@ -0,0 +1,158 @@
1
+ # pqax for Python
2
+
3
+ `pqax` reads, validates, and writes PQAX files: the footprint of Parquet tables, as a zip of JSON tables. The format is defined by the [serialization](https://github.com/pqax-org/pqax/blob/main/docs/serialization.md) and the [object model](https://github.com/pqax-org/pqax/blob/main/docs/object-model.md), and the validation rules by the [conformance kit](https://github.com/pqax-org/pqax/blob/main/conformance/rules.md).
4
+
5
+ | | |
6
+ |---|---|
7
+ | Version | 0.1.0 |
8
+ | Format | Reads every version 0.x. Writes version 0.1. |
9
+ | Python | 3.10 or later |
10
+ | Dependencies | None. `to_dataframe` uses pandas, or polars 1.0 or later, when one is installed. |
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ pip install pqax # the package and the pqax command
16
+ pip install "pqax[pandas]" # with pandas, for to_dataframe
17
+ pip install "pqax[polars]" # with polars, for to_dataframe
18
+ ```
19
+
20
+ From the root of the repository:
21
+
22
+ ```bash
23
+ pip install -e src/python # the package and the pqax command
24
+ pip install -e "src/python[pandas]" # with pandas, for to_dataframe
25
+ pip install -e "src/python[dev]" # with pytest and jsonschema, for the tests
26
+ ```
27
+
28
+ Python versions: 3.10 or later. The tests run on 3.10 and 3.14, each without extras and with both pandas and polars. The package has no dependency: the `pandas` and `polars` extras only install the library that `to_dataframe` uses.
29
+
30
+ ## Read a file
31
+
32
+ ```python
33
+ import pqax
34
+
35
+ with pqax.open("footprint.pqax") as doc:
36
+ print(doc.manifest["formatVersion"], doc.part_names)
37
+ tables = doc.summary["tables"] # read when the file opens
38
+ for table_id, size in zip(tables["id"], tables.get("fileSize", [])):
39
+ print(table_id, size)
40
+ chunks = doc.table("columnChunks") # read now, from all its parts, and kept
41
+ print(doc.row_count("columnChunks"), sum(chunks["compressedSize"]))
42
+ for row in doc.rows("files"): # one dictionary for each row
43
+ print(row["id"], row["fileSize"])
44
+ ```
45
+
46
+ A table is a dictionary of fields, and a field is a list with one value for each row, as in the file. The keys keep the names of the file, such as `fileCount`. A value that the file does not have is `None`.
47
+
48
+ - Opening a file reads the zip directory, `manifest.json`, and `summary.json`. A detail part is read the first time a table that it holds is asked for.
49
+ - `doc.table(name)` concatenates the parts of a table in the order of the manifest. A field that a part leaves out is `None` in the rows of that part. A table that the file does not have is `{}`.
50
+ - A field that has no value in any row is not in the dictionary: use `table.get("field")`.
51
+ - A value whose JSON type is not the type of its field, such as a string where the model says integer, reads as `None`. `doc.raw(name)` returns the table as parsed, with that value and with the members of a table that are not arrays.
52
+ - Fields and tables that this version does not define are returned like the others. Members of the manifest that it does not define stay in `doc.manifest`.
53
+
54
+ A PQAX file embedded in a VPAX file:
55
+
56
+ ```python
57
+ with pqax.open_embedded("model.vpax") as doc: # the part Footprint.pqax, stored or deflated
58
+ print(doc.embedded_part, doc.embedded_compression)
59
+ ```
60
+
61
+ The part is read into memory, and the VPAX file is closed before `open_embedded` returns.
62
+
63
+ ## Validate a file
64
+
65
+ ```python
66
+ result = pqax.validate("footprint.pqax") # or pqax.validate_embedded("model.vpax")
67
+ print(result.is_valid, result.checks)
68
+ for issue in result.issues:
69
+ print(issue.code, issue.message, issue.part, issue.table, issue.field)
70
+ ```
71
+
72
+ The validator reports every issue that it finds, with the codes of section 4.1 of the [contracts](https://github.com/pqax-org/pqax/blob/main/docs/contracts.md): `container.*`, `json.*`, `manifest.*`, `part.*`, `table.*`, `depth.*`, `totals.*`, and, for `validate_embedded`, `embedded.partMissing`. A rule that cannot be evaluated because an earlier one failed is skipped. A file that cannot be opened raises `OSError`. A file that is not a zip archive is an issue, `container.manifestMissing`, and so is a VPAX file given to `validate`.
73
+
74
+ ## Data frames
75
+
76
+ ```python
77
+ frame = pqax.to_dataframe(doc.table("columnChunks")) # pandas when installed, else polars
78
+ frame = pqax.to_dataframe(doc.summary["tables"], library="polars")
79
+ ```
80
+
81
+ With pandas, an integer or boolean column with `None` values gets a nullable type, so it does not turn into floats. Lists and objects stay as Python objects. Timestamps stay strings; `pqax.parse_timestamp` turns one into a `datetime`. The rows of `doc.rows(name)` give the same frame as `doc.table(name)`.
82
+
83
+ `to_dataframe` needs polars 1.0 or later, and raises `ImportError` with an earlier version, which loses the values of a column that mixes types.
84
+
85
+ ## Write a file
86
+
87
+ ```python
88
+ from datetime import datetime, timezone
89
+
90
+ builder = pqax.Builder({"collectorApp": "my-tool", "level": 2, "startedAt": datetime.now(timezone.utc)})
91
+ source = builder.add_source(kind="Storage folder")
92
+ table = builder.add_table(source=source, name="sales", format="Delta", detailDepth="Files")
93
+ file_id = builder.add_file(table=table, path="part-0001.parquet", fileSize=47820)
94
+ builder.update("tables", table, fileCount=1, fileSize=47820)
95
+ builder.write("footprint.pqax")
96
+ ```
97
+
98
+ | Method | What it does |
99
+ |---|---|
100
+ | `add_source(**fields)`, `add_table`, `add_column`, and one for each of the 21 tables | Adds a row. For a table with an `id`, it keeps a positive id that the row brings, assigns the next free id when the row has none (greater than every id so far in the table, so from 1), and returns the id. For a table without, it returns the position of the row, from 0. The plural names, such as `add_files`, are the same methods. |
101
+ | `add(table, **fields)` | Adds a row to any table, also one that this version does not define, with any field |
102
+ | `extend(table, columns, detail=None)` | Adds many rows from a columnar table. `detail` places a table that the format does not define. |
103
+ | `update(table, id_or_position, **fields)` | Sets fields of a row already added, such as totals that are known after the detail |
104
+ | `write(path_or_file)` | Writes the file. It raises `ValueError`, naming the table and the id, when a table has an id in more than one row. |
105
+ | `Builder.from_document(doc)` | A builder with every table of a document, to rewrite a file |
106
+
107
+ The fields are named as in the file. An `add_` method refuses a field that its table does not have, unless the name has a producer's prefix and an underscore, as in `acme_note`. A field set to `None` has no value. A `datetime` becomes a timestamp in UTC with three decimals; a naive `datetime` is taken as UTC.
108
+
109
+ `write` lays out the file as the serialization says: `manifest.json` first, then `summary.json`, always present, then one part for each detail table, named after it, as `files.json`. A table with more than `rows_per_part` rows, 250,000 by default and at most, continues in parts numbered from 1, as `columnChunks.1.json`. A field with no value in any row of a part is left out of that part, and so is a table with no rows. The JSON is compact UTF-8 with no byte order mark, the entries are deflated at level 9 by default (`compression_level`), and every entry has the same date, so the same footprint gives the same bytes. A path is written through a temporary file.
110
+
111
+ `from_document` keeps the ids, the collection, the obfuscation fields, the other members of the manifest, and the fields and tables that this version does not define. It loses what the reader does not keep: the entries that the manifest does not list, the members of a table that are not arrays, and the members of an element of `parts` other than `name` and `tables`. It writes format version 0.1, and its own part names.
112
+
113
+ ## Command line
114
+
115
+ ```bash
116
+ python -m pqax check footprint.pqax # exit code 0 when valid, 1 when not, 2 on a usage or I/O error
117
+ python -m pqax info footprint.pqax # format, version, collection, parts with their tables and rows
118
+ python -m pqax info model.vpax # a VPAX file: the PQAX file that it embeds as Footprint.pqax
119
+ ```
120
+
121
+ `check` prints one line for each issue, its code first, then a summary line. Both commands take `--part NAME` for another embedded part. The installed package also has a `pqax` command.
122
+
123
+ ## API
124
+
125
+ | Name | What it is |
126
+ |---|---|
127
+ | `open(path_or_file)` | A `PqaxDocument`. The source is a path, the bytes of a file, or a binary file object, which is left open. |
128
+ | `open_embedded(path_or_file, part_name="Footprint.pqax")` | A `PqaxDocument` of the PQAX file that a package holds as a part |
129
+ | `PqaxDocument` | `manifest`, `summary`, `row_count(name)`, `table(name)`, `rows(name)`, `raw(name, part=None)`, `table_names`, `part_names`, `format_version`, `collection`, `embedded_part`, `embedded_compression`, `close()`, and the `with` statement |
130
+ | `validate(path_or_file)`, `validate_embedded(path_or_file, part_name)` | A `ValidationResult`: `is_valid`, `checks`, `issues`, `codes` |
131
+ | `Issue` | `code`, `message`, `part`, `table`, `field` |
132
+ | `to_dataframe(table, library=None)` | A pandas or polars data frame |
133
+ | `Builder`, `BuilderOptions` | The writer |
134
+ | `format_timestamp(datetime)`, `parse_timestamp(text)` | Timestamps in the form of the format, `2026-10-05T08:15:30.000Z` |
135
+ | `TABLES`, `SUMMARY_TABLES`, `DETAIL_TABLES`, `FIELDS`, `REQUIRED`, `REFERENCES` | The names of the format |
136
+
137
+ Errors:
138
+
139
+ | Error | When |
140
+ |---|---|
141
+ | `ManifestMissingError` | The archive has no `manifest.json` |
142
+ | `FormatError` | `format` is not `pqax` |
143
+ | `UnknownVersionError` | The first number of `formatVersion` is not 0 |
144
+ | `JsonError` | A part that is read is not valid JSON |
145
+ | `PartMissingError` | A detail part that the manifest lists is not in the archive, when its table is read (`manifest.partMissing`), or the embedded part is not in the package (`embedded.partMissing`) |
146
+ | `ContainerError` | The file is not a zip archive, or an entry cannot be read: encrypted, compressed with another method, or corrupt |
147
+
148
+ All derive from `PqaxError`, whose `code` is the validation code that the error matches, when there is one. A reader refuses a file for the first four, when it opens it, and `open_embedded` also for a missing embedded part. Opening also fails when the manifest or the summary cannot be read from the zip, for example because the entry is encrypted. A `summary.json` that the manifest lists and the archive does not have gives empty summary tables, and `validate` reports it. A detail part that is broken fails only the read of its tables.
149
+
150
+ ## Limits
151
+
152
+ - The reader keeps a table in memory once it is read. A part of 250,000 column chunks takes about 0.15 s to parse.
153
+ - A part that holds more than 1 GB of JSON text is not read, and is reported or raised as such.
154
+ - The rows of a part are the length of its longest array, not what the manifest states, so that a file that states more rows than it holds cannot make the reader allocate them. In a valid file the two agree.
155
+ - The reader replaces a value of another JSON type with `None` only at the level of the field. An element of a list, or a member of a nested object, is returned as the file has it.
156
+ - `container.zip64` looks at the central directory and its locator, as the kit says. A ZIP64 field in a local header alone is not reported.
157
+ - No pandas or polars type is chosen for timestamps, lists, maps, or nested objects.
158
+ - On Python 3.10, `parse_timestamp` reads fewer of the other forms of ISO 8601, as `datetime.fromisoformat` does there: a fraction of seconds with other than three or six digits, a comma before the fraction, an offset without a colon, and the basic form, such as `20261005T081530Z`, raise `ValueError`. The form of the format reads on every version.
@@ -0,0 +1,34 @@
1
+ """Read, validate, and write PQAX files: the footprint of Parquet tables, as a zip of JSON tables.
2
+
3
+ import pqax
4
+
5
+ with pqax.open("footprint.pqax") as doc:
6
+ tables = doc.summary["tables"] # every summary table is read when the file opens
7
+ chunks = doc.table("columnChunks") # a detail table is read when it is asked for
8
+ print(sum(chunks.get("compressedSize", [])))
9
+
10
+ result = pqax.validate("footprint.pqax") # every issue, with the codes of the conformance kit
11
+ print(result.is_valid, [issue.code for issue in result.issues])
12
+
13
+ The package needs only the standard library. to_dataframe uses pandas or polars when one is installed.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ __version__ = "0.1.0"
18
+
19
+ from ._errors import (
20
+ ContainerError, FormatError, JsonError, ManifestMissingError, PartMissingError, PqaxError, UnknownVersionError,
21
+ )
22
+ from ._json import format_timestamp, parse_timestamp
23
+ from ._schema import DETAIL_TABLES, FIELDS, FORMAT_VERSION, REFERENCES, REQUIRED, ROWS_PER_PART, SUMMARY_TABLES, TABLES
24
+ from .builder import Builder, BuilderOptions
25
+ from .dataframe import to_dataframe
26
+ from .reader import PqaxDocument, open, open_embedded
27
+ from .validator import Issue, ValidationResult, validate, validate_embedded
28
+
29
+ __all__ = [
30
+ "Builder", "BuilderOptions", "ContainerError", "DETAIL_TABLES", "FIELDS", "FORMAT_VERSION", "FormatError",
31
+ "Issue", "JsonError", "ManifestMissingError", "PartMissingError", "PqaxDocument", "PqaxError", "REFERENCES",
32
+ "REQUIRED", "ROWS_PER_PART", "SUMMARY_TABLES", "TABLES", "UnknownVersionError", "ValidationResult",
33
+ "format_timestamp", "open", "open_embedded", "parse_timestamp", "to_dataframe", "validate", "validate_embedded",
34
+ ]
@@ -0,0 +1,133 @@
1
+ """The command line: python -m pqax check <file>, python -m pqax info <file>.
2
+
3
+ check exits with 0 when the file is valid, 1 when it is not, and 2 on a usage error or a file that cannot be read.
4
+ Both commands also take a VPAX file, and then work on the PQAX file that it embeds as Footprint.pqax.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import json
10
+ import sys
11
+ import zipfile
12
+ from typing import Any, List, Optional
13
+
14
+ from . import __version__
15
+ from ._container import find_entry, open_archive
16
+ from ._errors import ContainerError, PqaxError
17
+ from ._schema import EMBEDDED_PART, FORMAT_VERSION, MANIFEST_PART, SUMMARY_PART
18
+ from .reader import open as open_document, open_embedded
19
+ from .validator import validate, validate_embedded
20
+
21
+
22
+ def _embedded_part(path: str, part: Optional[str]) -> Optional[str]:
23
+ """The part to read when the file is a package: the one asked for, or Footprint.pqax when there is no manifest."""
24
+ if part:
25
+ return part
26
+ try:
27
+ archive, fp, owned = open_archive(path)
28
+ except ContainerError:
29
+ return None
30
+ try:
31
+ if MANIFEST_PART in archive.namelist():
32
+ return None
33
+ info = find_entry(archive, EMBEDDED_PART)
34
+ return info.filename if info is not None else None
35
+ finally:
36
+ archive.close()
37
+ if owned:
38
+ fp.close()
39
+
40
+
41
+ def _check(path: str, part: Optional[str]) -> int:
42
+ embedded = _embedded_part(path, part)
43
+ result = validate_embedded(path, embedded) if embedded else validate(path)
44
+ if embedded:
45
+ print(f"{path}: the PQAX file embedded as {embedded}")
46
+ for issue in result.issues:
47
+ print(issue)
48
+ verdict = "valid" if result.is_valid else f"invalid, {len(result.issues):,} issue(s)"
49
+ print(f"{path}: {verdict}, {result.checks:,} checks")
50
+ return 0 if result.is_valid else 1
51
+
52
+
53
+ def _text(value: Any) -> str:
54
+ return value if isinstance(value, str) else json.dumps(value, ensure_ascii=False)
55
+
56
+
57
+ def _info(path: str, part: Optional[str]) -> int:
58
+ embedded = _embedded_part(path, part)
59
+ with (open_embedded(path, embedded) if embedded else open_document(path)) as document:
60
+ manifest = document.manifest
61
+ lines: List[str] = [f"file: {path}"]
62
+ if document.embedded_part:
63
+ lines.append(f"embedded part: {document.embedded_part}, {document.embedded_compression}")
64
+ lines.append(f"format: {_text(manifest.get('format'))}")
65
+ lines.append(f"formatVersion: {_text(manifest.get('formatVersion'))}")
66
+ collection = manifest.get("collection")
67
+ if isinstance(collection, dict):
68
+ lines.append("collection: " + ", ".join(f"{name} {_text(value)}" for name, value in collection.items()))
69
+ for member in ("obfuscatorDictionaryId", "obfuscatorLib", "obfuscatorLibVersion"):
70
+ if member in manifest:
71
+ lines.append(f"{member}: {_text(manifest[member])}")
72
+ others = [name for name in manifest if name not in
73
+ ("format", "formatVersion", "collection", "parts", "obfuscatorDictionaryId", "obfuscatorLib",
74
+ "obfuscatorLibVersion")]
75
+ if others:
76
+ lines.append("other members: " + ", ".join(others))
77
+ parts = manifest.get("parts") if isinstance(manifest.get("parts"), list) else []
78
+ entries = {info.filename: info for info in document._archive.infolist()}
79
+ lines.append(f"parts: {len(parts)}")
80
+ width = max((len(str(item.get("name"))) for item in parts if isinstance(item, dict)), default=0)
81
+ for item in parts:
82
+ if not isinstance(item, dict):
83
+ continue
84
+ name = str(item.get("name"))
85
+ tables = item.get("tables") if isinstance(item.get("tables"), dict) else {}
86
+ rows = ", ".join(f"{table} {count:,}" if type(count) is int else f"{table} {_text(count)}"
87
+ for table, count in tables.items())
88
+ entry = entries.get(name)
89
+ size = (f"{entry.compress_size:>10,} bytes in the zip, {entry.file_size:>11,} of JSON"
90
+ if entry is not None else "missing from the archive")
91
+ lines.append(f" {name:<{width}} {size} {rows}")
92
+ total = {name: document.row_count(name) for name in document.table_names}
93
+ if total:
94
+ lines.append("rows: " + ", ".join(f"{name} {count:,}" for name, count in total.items()))
95
+ if SUMMARY_PART not in document.part_names:
96
+ lines.append("the manifest lists no summary.json")
97
+ print("\n".join(lines))
98
+ return 0
99
+
100
+
101
+ def main(argv: Optional[List[str]] = None) -> int:
102
+ """Runs the command line and returns the exit code."""
103
+ for stream in (sys.stdout, sys.stderr):
104
+ try:
105
+ stream.reconfigure(errors="backslashreplace") # type: ignore[union-attr]
106
+ except (AttributeError, ValueError):
107
+ pass
108
+ parser = argparse.ArgumentParser(prog="pqax", description="Check and describe PQAX files.")
109
+ parser.add_argument("--version", action="version",
110
+ version=f"pqax {__version__}, reads format 0.x, writes format {FORMAT_VERSION}")
111
+ commands = parser.add_subparsers(dest="command", metavar="command")
112
+ commands.required = True
113
+ check = commands.add_parser("check", help="validate a file; exit code 0 when valid, 1 when not")
114
+ info = commands.add_parser("info", help="print the manifest: format, version, collection, and parts")
115
+ for command in (check, info):
116
+ command.add_argument("file", help="a .pqax file, or a .vpax file that embeds one")
117
+ command.add_argument("--part", help=f"the embedded part of a package (default: {EMBEDDED_PART} when the file "
118
+ f"has no manifest.json)")
119
+ arguments = parser.parse_args(argv)
120
+ try:
121
+ if arguments.command == "check":
122
+ return _check(arguments.file, arguments.part)
123
+ return _info(arguments.file, arguments.part)
124
+ except OSError as error:
125
+ print(f"pqax: {error}", file=sys.stderr)
126
+ return 2
127
+ except (PqaxError, zipfile.BadZipFile) as error:
128
+ print(f"pqax: {error}", file=sys.stderr)
129
+ return 1
130
+
131
+
132
+ if __name__ == "__main__":
133
+ sys.exit(main())