tabalyst 0.1.1__tar.gz → 0.1.2__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.
- {tabalyst-0.1.1/src/tabalyst.egg-info → tabalyst-0.1.2}/PKG-INFO +5 -6
- {tabalyst-0.1.1 → tabalyst-0.1.2}/README.md +4 -5
- {tabalyst-0.1.1 → tabalyst-0.1.2}/pyproject.toml +1 -1
- {tabalyst-0.1.1 → tabalyst-0.1.2}/src/tabalyst/analysis.py +102 -5
- {tabalyst-0.1.1 → tabalyst-0.1.2}/src/tabalyst/models.py +38 -1
- {tabalyst-0.1.1 → tabalyst-0.1.2}/src/tabalyst/reporting.py +21 -2
- tabalyst-0.1.2/src/tabalyst/static/report.js +442 -0
- tabalyst-0.1.2/src/tabalyst/static/theme.css +484 -0
- tabalyst-0.1.2/src/tabalyst/templates/report.html +83 -0
- {tabalyst-0.1.1 → tabalyst-0.1.2/src/tabalyst.egg-info}/PKG-INFO +5 -6
- {tabalyst-0.1.1 → tabalyst-0.1.2}/tests/test_analysis.py +21 -1
- {tabalyst-0.1.1 → tabalyst-0.1.2}/tests/test_cli.py +3 -2
- {tabalyst-0.1.1 → tabalyst-0.1.2}/tests/test_public_api.py +1 -1
- {tabalyst-0.1.1 → tabalyst-0.1.2}/tests/test_reporting.py +30 -20
- tabalyst-0.1.1/src/tabalyst/static/report.js +0 -502
- tabalyst-0.1.1/src/tabalyst/static/theme.css +0 -427
- tabalyst-0.1.1/src/tabalyst/templates/report.html +0 -370
- {tabalyst-0.1.1 → tabalyst-0.1.2}/LICENSE +0 -0
- {tabalyst-0.1.1 → tabalyst-0.1.2}/MANIFEST.in +0 -0
- {tabalyst-0.1.1 → tabalyst-0.1.2}/examples/README.md +0 -0
- {tabalyst-0.1.1 → tabalyst-0.1.2}/examples/config.json +0 -0
- {tabalyst-0.1.1 → tabalyst-0.1.2}/examples/input/basic.csv +0 -0
- {tabalyst-0.1.1 → tabalyst-0.1.2}/examples/input/insurance-customers.csv +0 -0
- {tabalyst-0.1.1 → tabalyst-0.1.2}/setup.cfg +0 -0
- {tabalyst-0.1.1 → tabalyst-0.1.2}/src/tabalyst/__init__.py +0 -0
- {tabalyst-0.1.1 → tabalyst-0.1.2}/src/tabalyst/__main__.py +0 -0
- {tabalyst-0.1.1 → tabalyst-0.1.2}/src/tabalyst/_version.py +0 -0
- {tabalyst-0.1.1 → tabalyst-0.1.2}/src/tabalyst/cli.py +0 -0
- {tabalyst-0.1.1 → tabalyst-0.1.2}/src/tabalyst/config.py +0 -0
- {tabalyst-0.1.1 → tabalyst-0.1.2}/src/tabalyst/errors.py +0 -0
- {tabalyst-0.1.1 → tabalyst-0.1.2}/src/tabalyst/execution_log.py +0 -0
- {tabalyst-0.1.1 → tabalyst-0.1.2}/src/tabalyst/ingestion.py +0 -0
- {tabalyst-0.1.1 → tabalyst-0.1.2}/src/tabalyst/service.py +0 -0
- {tabalyst-0.1.1 → tabalyst-0.1.2}/src/tabalyst.egg-info/SOURCES.txt +0 -0
- {tabalyst-0.1.1 → tabalyst-0.1.2}/src/tabalyst.egg-info/dependency_links.txt +0 -0
- {tabalyst-0.1.1 → tabalyst-0.1.2}/src/tabalyst.egg-info/entry_points.txt +0 -0
- {tabalyst-0.1.1 → tabalyst-0.1.2}/src/tabalyst.egg-info/requires.txt +0 -0
- {tabalyst-0.1.1 → tabalyst-0.1.2}/src/tabalyst.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tabalyst
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
4
4
|
Summary: Open-source data profiling engine for structured JSON and customizable HTML reports.
|
|
5
5
|
Author: Gregory Borelli
|
|
6
6
|
License-Expression: MIT
|
|
@@ -162,13 +162,12 @@ See the complete layout and maintenance notes in the
|
|
|
162
162
|
- Physical and semantic type inference with explicit confidence and error rates.
|
|
163
163
|
- Numeric, date, string-length, normalization, and value-distribution profiles.
|
|
164
164
|
- A bounded raw-data preview while all records are analyzed.
|
|
165
|
-
- Sortable and filterable HTML tables.
|
|
165
|
+
- Sortable and filterable HTML tables in the self-contained Signature report.
|
|
166
166
|
|
|
167
167
|
Raw strings are preserved. The complete CSV is currently loaded into memory.
|
|
168
|
-
Report data remains local and Tabalyst adds no telemetry or remote analysis.
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
included in the Python package.
|
|
168
|
+
Report data remains local and Tabalyst adds no telemetry or remote analysis. The
|
|
169
|
+
report embeds its design system, font subsets and interaction code, so it works
|
|
170
|
+
without CDN or network access.
|
|
172
171
|
|
|
173
172
|
## Compatibility with alpha commands
|
|
174
173
|
|
|
@@ -128,13 +128,12 @@ See the complete layout and maintenance notes in the
|
|
|
128
128
|
- Physical and semantic type inference with explicit confidence and error rates.
|
|
129
129
|
- Numeric, date, string-length, normalization, and value-distribution profiles.
|
|
130
130
|
- A bounded raw-data preview while all records are analyzed.
|
|
131
|
-
- Sortable and filterable HTML tables.
|
|
131
|
+
- Sortable and filterable HTML tables in the self-contained Signature report.
|
|
132
132
|
|
|
133
133
|
Raw strings are preserved. The complete CSV is currently loaded into memory.
|
|
134
|
-
Report data remains local and Tabalyst adds no telemetry or remote analysis.
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
included in the Python package.
|
|
134
|
+
Report data remains local and Tabalyst adds no telemetry or remote analysis. The
|
|
135
|
+
report embeds its design system, font subsets and interaction code, so it works
|
|
136
|
+
without CDN or network access.
|
|
138
137
|
|
|
139
138
|
## Compatibility with alpha commands
|
|
140
139
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "tabalyst"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.2"
|
|
8
8
|
description = "Open-source data profiling engine for structured JSON and customizable HTML reports."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -14,6 +14,8 @@ from tabalyst.config import AnalysisConfig
|
|
|
14
14
|
from tabalyst.ingestion import CsvDataset
|
|
15
15
|
from tabalyst.models import (
|
|
16
16
|
ColumnProfile,
|
|
17
|
+
DatasetDateColumnSummary,
|
|
18
|
+
DatasetDateSummary,
|
|
17
19
|
DatasetProfile,
|
|
18
20
|
DatasetSummary,
|
|
19
21
|
DateBreakdownItem,
|
|
@@ -313,10 +315,15 @@ def build_date_profile(
|
|
|
313
315
|
return (
|
|
314
316
|
DateProfile(
|
|
315
317
|
status=status,
|
|
318
|
+
present_count=total,
|
|
316
319
|
valid_count=valid_count,
|
|
320
|
+
valid_percent=percent(valid_count, total),
|
|
317
321
|
ambiguous_count=ambiguous_count,
|
|
322
|
+
ambiguous_percent=percent(ambiguous_count, total),
|
|
318
323
|
invalid_date_count=invalid_count,
|
|
324
|
+
invalid_date_percent=percent(invalid_count, total),
|
|
319
325
|
not_date_count=not_date_count,
|
|
326
|
+
not_date_percent=percent(not_date_count, total),
|
|
320
327
|
resolved_ambiguous_order=resolved_order,
|
|
321
328
|
ambiguous_order_source=resolution_source,
|
|
322
329
|
formats=format_items,
|
|
@@ -406,15 +413,19 @@ def build_string_profile(
|
|
|
406
413
|
status = "very_long"
|
|
407
414
|
distribution = []
|
|
408
415
|
if maximum <= settings.length_distribution_max_length:
|
|
416
|
+
length_counts = lengths.value_counts()
|
|
417
|
+
maximum_length_count = int(length_counts.max())
|
|
409
418
|
for length in sorted({int(value) for value in lengths}):
|
|
410
419
|
matching = [
|
|
411
420
|
(value, count) for value, count in frequencies if len(value) == length
|
|
412
421
|
]
|
|
422
|
+
length_count = sum(count for _, count in matching)
|
|
413
423
|
distribution.append(
|
|
414
424
|
StringLengthDistribution(
|
|
415
425
|
length=length,
|
|
416
|
-
count=
|
|
417
|
-
percent=percent(
|
|
426
|
+
count=length_count,
|
|
427
|
+
percent=percent(length_count, len(present)),
|
|
428
|
+
relative_percent=percent(length_count, maximum_length_count),
|
|
418
429
|
distinct_count=len(matching),
|
|
419
430
|
examples=[
|
|
420
431
|
StringLengthExample(value=value, count=count)
|
|
@@ -432,6 +443,11 @@ def build_string_profile(
|
|
|
432
443
|
distinct_length_count=int(lengths.nunique()),
|
|
433
444
|
fixed_length=minimum if minimum == maximum else None,
|
|
434
445
|
length_distribution=distribution,
|
|
446
|
+
representative_examples=[
|
|
447
|
+
item.examples[0].value
|
|
448
|
+
for item in sorted(distribution, key=lambda item: -item.count)[:3]
|
|
449
|
+
if item.examples
|
|
450
|
+
],
|
|
435
451
|
)
|
|
436
452
|
|
|
437
453
|
|
|
@@ -632,7 +648,11 @@ def analyze_column(
|
|
|
632
648
|
stats = [numbers.min(), numbers.max(), numbers.mean(), numbers.median()]
|
|
633
649
|
if numbers.notna().all() and all(math.isfinite(value) for value in stats):
|
|
634
650
|
numeric = NumericStats(
|
|
635
|
-
minimum=stats[0],
|
|
651
|
+
minimum=stats[0],
|
|
652
|
+
maximum=stats[1],
|
|
653
|
+
range=stats[1] - stats[0],
|
|
654
|
+
mean=stats[2],
|
|
655
|
+
median=stats[3],
|
|
636
656
|
)
|
|
637
657
|
value_profile = build_value_profile(
|
|
638
658
|
frequencies,
|
|
@@ -649,6 +669,7 @@ def analyze_column(
|
|
|
649
669
|
if enum and semantic_type is None:
|
|
650
670
|
semantic_type = "enum"
|
|
651
671
|
string_profile = build_string_profile(present, frequencies, inferred, config)
|
|
672
|
+
missing_count = int(absent.sum())
|
|
652
673
|
return ColumnProfile(
|
|
653
674
|
id=f"column_{position}",
|
|
654
675
|
name=name if name is not None else str(values.name or ""),
|
|
@@ -658,8 +679,9 @@ def analyze_column(
|
|
|
658
679
|
type_confidence=round(type_confidence, 4),
|
|
659
680
|
type_error_count=type_error_count,
|
|
660
681
|
type_error_percent=type_error_percent,
|
|
661
|
-
missing_count=
|
|
662
|
-
missing_percent=percent(
|
|
682
|
+
missing_count=missing_count,
|
|
683
|
+
missing_percent=percent(missing_count, len(values)),
|
|
684
|
+
with_issues=missing_count > 0 or inferred == "mixed",
|
|
663
685
|
normalization=normalization,
|
|
664
686
|
distinct_count=len(frequencies),
|
|
665
687
|
examples=[
|
|
@@ -691,6 +713,40 @@ def analyze_dataset(dataset: CsvDataset, config: AnalysisConfig) -> DatasetProfi
|
|
|
691
713
|
empty = [column.id for column in columns if column.inferred_type == "empty"]
|
|
692
714
|
constant = [column.id for column in columns if column.distinct_count == 1]
|
|
693
715
|
mixed = [column.id for column in columns if column.inferred_type == "mixed"]
|
|
716
|
+
numeric_columns = [column for column in columns if column.numeric is not None]
|
|
717
|
+
date_columns = [column for column in columns if column.date_profile is not None]
|
|
718
|
+
string_columns = [column for column in columns if column.string_profile is not None]
|
|
719
|
+
inferred_type_counts = Counter(column.inferred_type for column in columns)
|
|
720
|
+
semantic_type_counts = Counter(column.semantic_type or "none" for column in columns)
|
|
721
|
+
date_present_count = sum(
|
|
722
|
+
column.date_profile.present_count for column in date_columns if column.date_profile
|
|
723
|
+
)
|
|
724
|
+
date_valid_count = sum(
|
|
725
|
+
column.date_profile.valid_count for column in date_columns if column.date_profile
|
|
726
|
+
)
|
|
727
|
+
date_ambiguous_count = sum(
|
|
728
|
+
column.date_profile.ambiguous_count
|
|
729
|
+
for column in date_columns
|
|
730
|
+
if column.date_profile
|
|
731
|
+
)
|
|
732
|
+
date_invalid_count = sum(
|
|
733
|
+
column.date_profile.invalid_date_count
|
|
734
|
+
for column in date_columns
|
|
735
|
+
if column.date_profile
|
|
736
|
+
)
|
|
737
|
+
date_not_date_count = sum(
|
|
738
|
+
column.date_profile.not_date_count
|
|
739
|
+
for column in date_columns
|
|
740
|
+
if column.date_profile
|
|
741
|
+
)
|
|
742
|
+
maximum_column_ambiguous_count = max(
|
|
743
|
+
(
|
|
744
|
+
column.date_profile.ambiguous_count
|
|
745
|
+
for column in date_columns
|
|
746
|
+
if column.date_profile
|
|
747
|
+
),
|
|
748
|
+
default=0,
|
|
749
|
+
)
|
|
694
750
|
issues = []
|
|
695
751
|
|
|
696
752
|
def add_issue(
|
|
@@ -774,6 +830,47 @@ def analyze_dataset(dataset: CsvDataset, config: AnalysisConfig) -> DatasetProfi
|
|
|
774
830
|
empty_row_count=int(absent.all(axis=1).sum()),
|
|
775
831
|
empty_column_count=len(empty),
|
|
776
832
|
constant_column_count=len(constant),
|
|
833
|
+
with_issues_column_count=sum(column.with_issues for column in columns),
|
|
834
|
+
numeric_column_count=len(numeric_columns),
|
|
835
|
+
date_column_count=len(date_columns),
|
|
836
|
+
string_column_count=len(string_columns),
|
|
837
|
+
inferred_type_counts=dict(inferred_type_counts),
|
|
838
|
+
inferred_type_percents={
|
|
839
|
+
name: percent(count, len(columns))
|
|
840
|
+
for name, count in inferred_type_counts.items()
|
|
841
|
+
},
|
|
842
|
+
semantic_type_counts=dict(semantic_type_counts),
|
|
843
|
+
semantic_type_percents={
|
|
844
|
+
name: percent(count, len(columns))
|
|
845
|
+
for name, count in semantic_type_counts.items()
|
|
846
|
+
},
|
|
847
|
+
),
|
|
848
|
+
date_summary=(
|
|
849
|
+
DatasetDateSummary(
|
|
850
|
+
present_count=date_present_count,
|
|
851
|
+
valid_count=date_valid_count,
|
|
852
|
+
ambiguous_count=date_ambiguous_count,
|
|
853
|
+
ambiguous_percent=percent(date_ambiguous_count, date_present_count),
|
|
854
|
+
invalid_date_count=date_invalid_count,
|
|
855
|
+
not_date_count=date_not_date_count,
|
|
856
|
+
maximum_column_ambiguous_count=maximum_column_ambiguous_count,
|
|
857
|
+
columns=[
|
|
858
|
+
DatasetDateColumnSummary(
|
|
859
|
+
column_id=column.id,
|
|
860
|
+
name=column.name,
|
|
861
|
+
position=column.position,
|
|
862
|
+
ambiguous_count=column.date_profile.ambiguous_count,
|
|
863
|
+
relative_ambiguous_percent=percent(
|
|
864
|
+
column.date_profile.ambiguous_count,
|
|
865
|
+
maximum_column_ambiguous_count,
|
|
866
|
+
),
|
|
867
|
+
)
|
|
868
|
+
for column in date_columns
|
|
869
|
+
if column.date_profile and column.date_profile.ambiguous_count
|
|
870
|
+
],
|
|
871
|
+
)
|
|
872
|
+
if date_columns
|
|
873
|
+
else None
|
|
777
874
|
),
|
|
778
875
|
columns=columns,
|
|
779
876
|
issues=issues,
|
|
@@ -23,6 +23,7 @@ class SourceInfo(ResultModel):
|
|
|
23
23
|
class NumericStats(ResultModel):
|
|
24
24
|
minimum: FiniteFloat
|
|
25
25
|
maximum: FiniteFloat
|
|
26
|
+
range: FiniteFloat
|
|
26
27
|
mean: FiniteFloat
|
|
27
28
|
median: FiniteFloat
|
|
28
29
|
|
|
@@ -52,10 +53,15 @@ class DateBreakdownItem(ResultModel):
|
|
|
52
53
|
|
|
53
54
|
class DateProfile(ResultModel):
|
|
54
55
|
status: Literal["valid", "multiple_formats", "mixed", "ambiguous", "invalid"]
|
|
56
|
+
present_count: int
|
|
55
57
|
valid_count: int
|
|
58
|
+
valid_percent: float
|
|
56
59
|
ambiguous_count: int
|
|
60
|
+
ambiguous_percent: float
|
|
57
61
|
invalid_date_count: int
|
|
62
|
+
invalid_date_percent: float
|
|
58
63
|
not_date_count: int
|
|
64
|
+
not_date_percent: float
|
|
59
65
|
resolved_ambiguous_order: Literal["MDY", "DMY"] | None = None
|
|
60
66
|
ambiguous_order_source: Literal["config", "column"] | None = None
|
|
61
67
|
formats: list[DateFormatCount]
|
|
@@ -73,6 +79,7 @@ class StringLengthDistribution(ResultModel):
|
|
|
73
79
|
length: int
|
|
74
80
|
count: int
|
|
75
81
|
percent: float
|
|
82
|
+
relative_percent: float
|
|
76
83
|
distinct_count: int
|
|
77
84
|
examples: list[StringLengthExample]
|
|
78
85
|
|
|
@@ -87,6 +94,7 @@ class StringProfile(ResultModel):
|
|
|
87
94
|
distinct_length_count: int
|
|
88
95
|
fixed_length: int | None = None
|
|
89
96
|
length_distribution: list[StringLengthDistribution]
|
|
97
|
+
representative_examples: list[str]
|
|
90
98
|
|
|
91
99
|
|
|
92
100
|
class ValueOccurrence(ResultModel):
|
|
@@ -122,6 +130,7 @@ class ColumnProfile(ResultModel):
|
|
|
122
130
|
type_error_percent: float | None
|
|
123
131
|
missing_count: int
|
|
124
132
|
missing_percent: float
|
|
133
|
+
with_issues: bool
|
|
125
134
|
normalization: NormalizationStats
|
|
126
135
|
distinct_count: int
|
|
127
136
|
examples: list[str]
|
|
@@ -145,6 +154,33 @@ class DatasetSummary(ResultModel):
|
|
|
145
154
|
empty_row_count: int
|
|
146
155
|
empty_column_count: int
|
|
147
156
|
constant_column_count: int
|
|
157
|
+
with_issues_column_count: int
|
|
158
|
+
numeric_column_count: int
|
|
159
|
+
date_column_count: int
|
|
160
|
+
string_column_count: int
|
|
161
|
+
inferred_type_counts: dict[str, int]
|
|
162
|
+
inferred_type_percents: dict[str, float]
|
|
163
|
+
semantic_type_counts: dict[str, int]
|
|
164
|
+
semantic_type_percents: dict[str, float]
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class DatasetDateColumnSummary(ResultModel):
|
|
168
|
+
column_id: str
|
|
169
|
+
name: str
|
|
170
|
+
position: int
|
|
171
|
+
ambiguous_count: int
|
|
172
|
+
relative_ambiguous_percent: float
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class DatasetDateSummary(ResultModel):
|
|
176
|
+
present_count: int
|
|
177
|
+
valid_count: int
|
|
178
|
+
ambiguous_count: int
|
|
179
|
+
ambiguous_percent: float
|
|
180
|
+
invalid_date_count: int
|
|
181
|
+
not_date_count: int
|
|
182
|
+
maximum_column_ambiguous_count: int
|
|
183
|
+
columns: list[DatasetDateColumnSummary]
|
|
148
184
|
|
|
149
185
|
|
|
150
186
|
class Issue(ResultModel):
|
|
@@ -163,12 +199,13 @@ class PreviewRow(ResultModel):
|
|
|
163
199
|
|
|
164
200
|
class DatasetProfile(ResultModel):
|
|
165
201
|
format_version: Literal["0.1.0a"] = "0.1.0a"
|
|
166
|
-
format_revision: Literal[
|
|
202
|
+
format_revision: Literal[2] = 2
|
|
167
203
|
generated_at: datetime
|
|
168
204
|
processing_seconds: FiniteFloat
|
|
169
205
|
source: SourceInfo
|
|
170
206
|
config: AnalysisConfig
|
|
171
207
|
summary: DatasetSummary
|
|
208
|
+
date_summary: DatasetDateSummary | None = None
|
|
172
209
|
columns: list[ColumnProfile]
|
|
173
210
|
issues: list[Issue]
|
|
174
211
|
preview: list[PreviewRow]
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
"""Render self-contained analysis results as an interactive HTML report."""
|
|
2
2
|
|
|
3
|
-
from collections import Counter
|
|
4
3
|
from importlib.resources import files
|
|
5
4
|
from pathlib import Path
|
|
6
5
|
|
|
@@ -45,10 +44,30 @@ def render_report(profile: DatasetProfile) -> str:
|
|
|
45
44
|
template = environment.from_string(
|
|
46
45
|
resources.joinpath("templates/report.html").read_text(encoding="utf-8")
|
|
47
46
|
)
|
|
47
|
+
type_colors = {
|
|
48
|
+
"text": "var(--d2)",
|
|
49
|
+
"mixed": "var(--attn)",
|
|
50
|
+
"integer": "var(--d3)",
|
|
51
|
+
"number": "var(--d1)",
|
|
52
|
+
"boolean": "var(--ok)",
|
|
53
|
+
"date": "var(--d1)",
|
|
54
|
+
"empty": "var(--line-strong)",
|
|
55
|
+
}
|
|
56
|
+
semantic_colors = {
|
|
57
|
+
"enum": "var(--d2)",
|
|
58
|
+
"date": "var(--d1)",
|
|
59
|
+
"none": "var(--d3)",
|
|
60
|
+
}
|
|
48
61
|
return template.render(
|
|
49
62
|
report=profile,
|
|
50
63
|
tabalyst_version=get_version(),
|
|
51
|
-
|
|
64
|
+
source_stem=Path(profile.source.filename).stem,
|
|
65
|
+
source_suffix=Path(profile.source.filename).suffix,
|
|
66
|
+
numeric_columns=[column for column in profile.columns if column.numeric],
|
|
67
|
+
date_columns=[column for column in profile.columns if column.date_profile],
|
|
68
|
+
string_columns=[column for column in profile.columns if column.string_profile],
|
|
69
|
+
type_colors=type_colors,
|
|
70
|
+
semantic_colors=semantic_colors,
|
|
52
71
|
theme_css=resources.joinpath("static/theme.css").read_text(encoding="utf-8"),
|
|
53
72
|
report_js=resources.joinpath("static/report.js").read_text(encoding="utf-8"),
|
|
54
73
|
)
|