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 +21 -0
- pqax-0.1.0/PKG-INFO +190 -0
- pqax-0.1.0/README.md +158 -0
- pqax-0.1.0/pqax/__init__.py +34 -0
- pqax-0.1.0/pqax/__main__.py +133 -0
- pqax-0.1.0/pqax/_container.py +135 -0
- pqax-0.1.0/pqax/_errors.py +51 -0
- pqax-0.1.0/pqax/_json.py +83 -0
- pqax-0.1.0/pqax/_schema.py +297 -0
- pqax-0.1.0/pqax/builder.py +505 -0
- pqax-0.1.0/pqax/dataframe.py +93 -0
- pqax-0.1.0/pqax/py.typed +0 -0
- pqax-0.1.0/pqax/reader.py +338 -0
- pqax-0.1.0/pqax/validator.py +873 -0
- pqax-0.1.0/pqax.egg-info/PKG-INFO +190 -0
- pqax-0.1.0/pqax.egg-info/SOURCES.txt +20 -0
- pqax-0.1.0/pqax.egg-info/dependency_links.txt +1 -0
- pqax-0.1.0/pqax.egg-info/entry_points.txt +2 -0
- pqax-0.1.0/pqax.egg-info/requires.txt +10 -0
- pqax-0.1.0/pqax.egg-info/top_level.txt +1 -0
- pqax-0.1.0/pyproject.toml +46 -0
- pqax-0.1.0/setup.cfg +4 -0
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())
|