jxcel 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
jxcel/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """jxcel: JSON dataset to Excel workbook conversion."""
2
+
3
+ __version__ = "0.1.0"
jxcel/cli.py ADDED
@@ -0,0 +1,420 @@
1
+ """Command-line interface and pipeline orchestration for jxcel."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Sequence
10
+
11
+ from . import __version__
12
+ from .discovery import DatasetCandidate, discover
13
+ from .jsonio import JsonLoadError, load_json
14
+ from .planning import (
15
+ DatasetPlan,
16
+ PlanningError,
17
+ PreparedValue,
18
+ build_plan,
19
+ preview_rows,
20
+ )
21
+ from .reporting import (
22
+ render_export,
23
+ render_listing,
24
+ render_plan,
25
+ render_preview,
26
+ )
27
+ from .workbook import (
28
+ WorkbookError,
29
+ assign_sheet_names,
30
+ export_workbook,
31
+ validate_output_destination,
32
+ )
33
+
34
+
35
+ def _positive_integer(value: str) -> int:
36
+ try:
37
+ number = int(value)
38
+ except ValueError as exc:
39
+ raise argparse.ArgumentTypeError(
40
+ f"invalid integer value: {value!r}"
41
+ ) from exc
42
+ if number < 1:
43
+ raise argparse.ArgumentTypeError("must be a positive integer")
44
+ return number
45
+
46
+
47
+ def _build_parser() -> argparse.ArgumentParser:
48
+ parser = argparse.ArgumentParser(
49
+ prog="jxcel",
50
+ description=(
51
+ "Convert JSON datasets into a formatted Excel .xlsx workbook."
52
+ ),
53
+ )
54
+ parser.add_argument(
55
+ "input",
56
+ nargs="?",
57
+ metavar="INPUT",
58
+ help="input JSON file (mutually exclusive with -i/--input)",
59
+ )
60
+ parser.add_argument(
61
+ "-i",
62
+ "--input",
63
+ dest="input_option",
64
+ metavar="PATH",
65
+ help="input JSON file (mutually exclusive with positional INPUT)",
66
+ )
67
+ parser.add_argument(
68
+ "-o",
69
+ "--output",
70
+ metavar="PATH",
71
+ help=(
72
+ "output .xlsx path; defaults to the input name with .xlsx "
73
+ "beside the input"
74
+ ),
75
+ )
76
+ parser.add_argument(
77
+ "--overwrite",
78
+ action="store_true",
79
+ help="replace an existing output file after validation",
80
+ )
81
+ parser.add_argument(
82
+ "-d",
83
+ "--dataset",
84
+ action="append",
85
+ default=[],
86
+ metavar="JSON_POINTER",
87
+ help=(
88
+ "select a dataset by JSON Pointer; repeat to select more "
89
+ "than one (default: all discovered datasets)"
90
+ ),
91
+ )
92
+ parser.add_argument(
93
+ "--list",
94
+ dest="list_mode",
95
+ action="store_true",
96
+ help="list datasets and unsupported structures without writing",
97
+ )
98
+ parser.add_argument(
99
+ "--preview",
100
+ action="store_true",
101
+ help="show bounded dataset records without writing",
102
+ )
103
+ parser.add_argument(
104
+ "--preview-rows",
105
+ type=_positive_integer,
106
+ default=None,
107
+ metavar="N",
108
+ help="number of preview rows per dataset (default: 5)",
109
+ )
110
+ parser.add_argument(
111
+ "--dry-run",
112
+ action="store_true",
113
+ help="validate and show the complete plan without writing",
114
+ )
115
+ parser.add_argument(
116
+ "--column-map",
117
+ action="append",
118
+ default=[],
119
+ metavar="JSON_PAIR",
120
+ help=(
121
+ "include an exact column using "
122
+ '\'["/dataset/pointer","column"]\'; repeatable'
123
+ ),
124
+ )
125
+ parser.add_argument(
126
+ "--exclude-map",
127
+ action="append",
128
+ default=[],
129
+ metavar="JSON_PAIR",
130
+ help=(
131
+ "exclude an exact column using "
132
+ '\'["/dataset/pointer","column"]\'; repeatable'
133
+ ),
134
+ )
135
+ parser.add_argument(
136
+ "--no-flatten",
137
+ action="store_true",
138
+ help=(
139
+ "use one column per top-level property and keep nested "
140
+ "objects as compact JSON text"
141
+ ),
142
+ )
143
+ verbosity = parser.add_mutually_exclusive_group()
144
+ verbosity.add_argument(
145
+ "-v",
146
+ "--verbose",
147
+ action="store_true",
148
+ help="add diagnostic detail to reports",
149
+ )
150
+ verbosity.add_argument(
151
+ "-q",
152
+ "--quiet",
153
+ action="store_true",
154
+ help=(
155
+ "suppress the routine success summary; warnings and "
156
+ "findings remain visible"
157
+ ),
158
+ )
159
+ parser.add_argument(
160
+ "--version",
161
+ action="version",
162
+ version=f"%(prog)s {__version__}",
163
+ )
164
+ return parser
165
+
166
+
167
+ def _validate_arguments(
168
+ parser: argparse.ArgumentParser,
169
+ args: argparse.Namespace,
170
+ ) -> None:
171
+ if args.input is not None and args.input_option is not None:
172
+ parser.error(
173
+ "provide the input either positionally or with -i/--input, "
174
+ "not both"
175
+ )
176
+ if args.input is None and args.input_option is None:
177
+ parser.error("an input JSON file is required")
178
+
179
+ if args.list_mode and (args.preview or args.dry_run):
180
+ parser.error(
181
+ "--list cannot be combined with --preview or --dry-run"
182
+ )
183
+ if args.preview_rows is not None and not args.preview:
184
+ parser.error("--preview-rows requires --preview")
185
+
186
+ preview_only = args.preview and not args.dry_run
187
+ if args.list_mode or preview_only:
188
+ if args.output is not None:
189
+ parser.error(
190
+ "--output is not valid with --list or preview-only mode"
191
+ )
192
+ if args.overwrite:
193
+ parser.error(
194
+ "--overwrite is not valid with --list or preview-only mode"
195
+ )
196
+
197
+ if args.preview and args.preview_rows is None:
198
+ args.preview_rows = 5
199
+
200
+
201
+ def _emit(text: str) -> None:
202
+ if text:
203
+ print(text)
204
+ sys.stdout.flush()
205
+
206
+
207
+ def _fail(message: str) -> None:
208
+ try:
209
+ print(f"jxcel: error: {message}", file=sys.stderr)
210
+ sys.stderr.flush()
211
+ except BrokenPipeError:
212
+ _redirect_console_after_broken_pipe()
213
+
214
+
215
+ def _configure_console_encoding() -> None:
216
+ """Escape characters the active console encoding cannot represent."""
217
+ for stream in (sys.stdout, sys.stderr):
218
+ reconfigure = getattr(stream, "reconfigure", None)
219
+ if reconfigure is None:
220
+ continue
221
+ try:
222
+ reconfigure(errors="backslashreplace")
223
+ except (OSError, ValueError):
224
+ # Some wrapped or already-closed streams cannot be reconfigured.
225
+ # Their normal write errors are handled at the CLI boundary.
226
+ continue
227
+
228
+
229
+ def _redirect_console_after_broken_pipe() -> None:
230
+ """Prevent buffered console streams from raising again during shutdown."""
231
+ for stream in (sys.stdout, sys.stderr):
232
+ try:
233
+ descriptor = stream.fileno()
234
+ except (AttributeError, OSError, ValueError):
235
+ continue
236
+ try:
237
+ null_descriptor = os.open(os.devnull, os.O_WRONLY)
238
+ try:
239
+ os.dup2(null_descriptor, descriptor)
240
+ finally:
241
+ os.close(null_descriptor)
242
+ except OSError:
243
+ continue
244
+
245
+
246
+ def _exportable_plans(
247
+ plans: Sequence[DatasetPlan],
248
+ ) -> tuple[DatasetPlan, ...]:
249
+ return tuple(
250
+ plan
251
+ for plan in plans
252
+ if plan.selected and plan.exportable
253
+ )
254
+
255
+
256
+ def _preview_rows_by_pointer(
257
+ plans: Sequence[DatasetPlan],
258
+ limit: int,
259
+ ) -> dict[str, tuple[tuple[PreparedValue, ...], ...]]:
260
+ return {
261
+ plan.pointer: tuple(preview_rows(plan, limit))
262
+ for plan in plans
263
+ }
264
+
265
+
266
+ def main(argv: Sequence[str] | None = None) -> int:
267
+ """Run the jxcel command and return its exit status."""
268
+ _configure_console_encoding()
269
+ parser = _build_parser()
270
+ try:
271
+ args = parser.parse_args(argv)
272
+ _validate_arguments(parser, args)
273
+ except BrokenPipeError:
274
+ _redirect_console_after_broken_pipe()
275
+ return 1
276
+
277
+ input_path = Path(
278
+ args.input if args.input is not None else args.input_option
279
+ )
280
+
281
+ try:
282
+ document = load_json(input_path)
283
+ findings = discover(document)
284
+ candidates = tuple(
285
+ finding
286
+ for finding in findings
287
+ if isinstance(finding, DatasetCandidate)
288
+ )
289
+
290
+ schema_only = args.list_mode or (
291
+ args.preview and not args.dry_run
292
+ )
293
+ result = build_plan(
294
+ candidates,
295
+ selected_pointers=args.dataset or None,
296
+ include_maps=args.column_map,
297
+ exclude_maps=args.exclude_map,
298
+ flatten=not args.no_flatten,
299
+ validation_mode="schema" if schema_only else "export",
300
+ )
301
+ plans = result.datasets
302
+ exportable = _exportable_plans(plans)
303
+
304
+ if args.list_mode:
305
+ _emit(
306
+ render_listing(
307
+ plans,
308
+ findings,
309
+ input_path=input_path,
310
+ verbose=args.verbose,
311
+ )
312
+ )
313
+ return 0
314
+
315
+ if args.preview and not args.dry_run:
316
+ rows = _preview_rows_by_pointer(
317
+ exportable,
318
+ args.preview_rows,
319
+ )
320
+ _emit(
321
+ render_preview(
322
+ plans,
323
+ rows,
324
+ findings,
325
+ input_path=input_path,
326
+ verbose=args.verbose,
327
+ )
328
+ )
329
+ return 0
330
+
331
+ if args.output is not None:
332
+ output_path = Path(args.output)
333
+ else:
334
+ output_path = input_path.with_suffix(".xlsx")
335
+
336
+ if args.dry_run:
337
+ if not exportable:
338
+ _emit(
339
+ render_listing(
340
+ plans,
341
+ findings,
342
+ input_path=input_path,
343
+ verbose=args.verbose,
344
+ )
345
+ )
346
+ _fail("No selected exportable datasets to write")
347
+ return 1
348
+ validate_output_destination(
349
+ output_path,
350
+ overwrite=args.overwrite,
351
+ input_path=input_path,
352
+ )
353
+ sheet_names = None
354
+ if args.verbose:
355
+ sheet_names = {
356
+ assignment.pointer: assignment.name
357
+ for assignment in assign_sheet_names(exportable)
358
+ }
359
+ sections = [
360
+ render_plan(
361
+ plans,
362
+ findings,
363
+ output_path,
364
+ overwrite=args.overwrite,
365
+ mapping_outcomes=result.mapping_outcomes,
366
+ sheet_names=sheet_names,
367
+ input_path=input_path,
368
+ verbose=args.verbose,
369
+ )
370
+ ]
371
+ if args.preview:
372
+ rows = _preview_rows_by_pointer(
373
+ exportable,
374
+ args.preview_rows,
375
+ )
376
+ sections.append(
377
+ render_preview(plans, rows, ())
378
+ )
379
+ _emit("\n\n".join(sections))
380
+ return 0
381
+
382
+ if not exportable:
383
+ _emit(
384
+ render_listing(
385
+ plans,
386
+ findings,
387
+ input_path=input_path,
388
+ verbose=args.verbose,
389
+ )
390
+ )
391
+ _fail("No selected exportable datasets to write")
392
+ return 1
393
+
394
+ export = export_workbook(
395
+ plans,
396
+ output_path,
397
+ overwrite=args.overwrite,
398
+ input_path=input_path,
399
+ )
400
+ _emit(
401
+ render_export(
402
+ export,
403
+ plans,
404
+ findings,
405
+ input_path=input_path,
406
+ quiet=args.quiet,
407
+ verbose=args.verbose,
408
+ )
409
+ )
410
+ return 0
411
+ except (JsonLoadError, PlanningError, WorkbookError) as exc:
412
+ _fail(str(exc))
413
+ return 1
414
+ except BrokenPipeError:
415
+ _redirect_console_after_broken_pipe()
416
+ return 1
417
+
418
+
419
+ if __name__ == "__main__":
420
+ raise SystemExit(main())
jxcel/discovery.py ADDED
@@ -0,0 +1,130 @@
1
+ """Deterministic dataset discovery for parsed JSON documents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any, Literal
7
+
8
+
9
+ StructureKind = Literal[
10
+ "empty_array",
11
+ "mixed_array",
12
+ "nested_array",
13
+ "primitive_array",
14
+ "unsupported_root",
15
+ "scalar_value",
16
+ "empty_object",
17
+ ]
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class DatasetCandidate:
22
+ """An array whose direct elements are all JSON objects."""
23
+
24
+ pointer: str
25
+ records: tuple[dict[str, Any], ...]
26
+
27
+ @property
28
+ def kind(self) -> Literal["dataset"]:
29
+ return "dataset"
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class StructureFinding:
34
+ """An array or root value that cannot be treated as a dataset."""
35
+
36
+ pointer: str
37
+ kind: StructureKind
38
+
39
+
40
+ Finding = DatasetCandidate | StructureFinding
41
+
42
+
43
+ def _escape_pointer_token(token: str) -> str:
44
+ """Escape one JSON Pointer reference token."""
45
+ return token.replace("~", "~0").replace("/", "~1")
46
+
47
+
48
+ def _object_child_pointer(pointer: str, key: str) -> str:
49
+ return f"{pointer}/{_escape_pointer_token(key)}"
50
+
51
+
52
+ def _array_child_pointer(pointer: str, index: int) -> str:
53
+ return f"{pointer}/{index}"
54
+
55
+
56
+ def _classify_array(
57
+ values: list[Any],
58
+ ) -> StructureKind | Literal["dataset"]:
59
+ """Classify one array using the approved precedence exactly."""
60
+ if not values:
61
+ return "empty_array"
62
+ if all(isinstance(item, dict) for item in values):
63
+ return "dataset"
64
+ if any(isinstance(item, dict) for item in values):
65
+ return "mixed_array"
66
+ if any(isinstance(item, list) for item in values):
67
+ return "nested_array"
68
+ return "primitive_array"
69
+
70
+
71
+ def discover(document: object) -> tuple[Finding, ...]:
72
+ """Return deterministic discovery findings for a parsed JSON document.
73
+
74
+ Traversal is depth-first and pre-order. Object values are visited in source
75
+ key order and array elements by ascending index. Dataset arrays are recorded
76
+ with their original record dictionaries and are not traversed further.
77
+ """
78
+ if not isinstance(document, (dict, list)):
79
+ return (StructureFinding(pointer="", kind="unsupported_root"),)
80
+
81
+ findings: list[Finding] = []
82
+ # The boolean tracks whether an ancestor unsupported-array finding already
83
+ # accounts for values in this branch. Continue traversing those branches to
84
+ # find independent arrays/datasets, but avoid redundant scalar/object
85
+ # findings beneath the array finding.
86
+ stack: list[tuple[object, str, bool]] = [(document, "", False)]
87
+
88
+ while stack:
89
+ value, pointer, covered_by_array_finding = stack.pop()
90
+
91
+ if isinstance(value, dict):
92
+ if not value:
93
+ if not covered_by_array_finding:
94
+ findings.append(
95
+ StructureFinding(pointer=pointer, kind="empty_object")
96
+ )
97
+ continue
98
+ for key, child in reversed(list(value.items())):
99
+ stack.append(
100
+ (
101
+ child,
102
+ _object_child_pointer(pointer, key),
103
+ covered_by_array_finding,
104
+ )
105
+ )
106
+ continue
107
+
108
+ if not isinstance(value, list):
109
+ if not covered_by_array_finding:
110
+ findings.append(
111
+ StructureFinding(pointer=pointer, kind="scalar_value")
112
+ )
113
+ continue
114
+
115
+ kind = _classify_array(value)
116
+ if kind == "dataset":
117
+ findings.append(DatasetCandidate(pointer=pointer, records=tuple(value)))
118
+ continue
119
+
120
+ findings.append(StructureFinding(pointer=pointer, kind=kind))
121
+ for index in range(len(value) - 1, -1, -1):
122
+ stack.append(
123
+ (
124
+ value[index],
125
+ _array_child_pointer(pointer, index),
126
+ True,
127
+ )
128
+ )
129
+
130
+ return tuple(findings)