macrotrace 0.1.0rc1__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.
macrotrace/__init__.py ADDED
@@ -0,0 +1,39 @@
1
+ """Macrotrace top-level package exports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib.metadata import PackageNotFoundError, version
6
+ from typing import Any
7
+
8
+ try:
9
+ __version__ = version("macrotrace")
10
+ except PackageNotFoundError:
11
+ __version__ = "unknown"
12
+
13
+ __all__ = [
14
+ "MTTimeSeries",
15
+ "MTObservation",
16
+ "MTSeriesMetadata",
17
+ "MTTimeSeriesPlotter",
18
+ "VintageComparison",
19
+ "__version__",
20
+ ]
21
+
22
+ _LAZY_IMPORTS = {
23
+ "MTTimeSeries": ("macrotrace.models.mt.time_series", "MTTimeSeries"),
24
+ "MTObservation": ("macrotrace.models.mt.observation", "MTObservation"),
25
+ "MTSeriesMetadata": ("macrotrace.models.mt.series_metadata", "MTSeriesMetadata"),
26
+ "MTTimeSeriesPlotter": ("macrotrace.models.mt.plotter", "MTTimeSeriesPlotter"),
27
+ "VintageComparison": ("macrotrace.models.mt.analysis", "VintageComparison"),
28
+ }
29
+
30
+
31
+ def __getattr__(name: str) -> Any:
32
+ """Lazily expose heavy model imports at package level."""
33
+ target = _LAZY_IMPORTS.get(name)
34
+ if target is None:
35
+ raise AttributeError(f"module 'macrotrace' has no attribute {name!r}")
36
+ module_name, attr = target
37
+ from importlib import import_module
38
+
39
+ return getattr(import_module(module_name), attr)
macrotrace/_paths.py ADDED
@@ -0,0 +1,37 @@
1
+ """Path resolution for macrotrace's SQLite files.
2
+
3
+ Resolution order for both files: explicit argument, then environment
4
+ variable, then default in the current working directory. If
5
+ ``MACROTRACE_DB`` is set but ``MACROTRACE_CACHE`` is not, the cache
6
+ defaults to sitting next to the database file.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ from pathlib import Path
13
+ from typing import Optional
14
+
15
+ DB_ENV_VAR = "MACROTRACE_DB"
16
+ CACHE_ENV_VAR = "MACROTRACE_CACHE"
17
+
18
+ DEFAULT_DB_NAME = "MacroTrace.db"
19
+ DEFAULT_CACHE_NAME = "MacroTraceRequestCache.sqlite"
20
+
21
+
22
+ def resolve_db_path(arg: Optional[str] = None) -> str:
23
+ if arg is not None:
24
+ return arg
25
+ return os.environ.get(DB_ENV_VAR) or DEFAULT_DB_NAME
26
+
27
+
28
+ def resolve_cache_path(arg: Optional[str] = None) -> str:
29
+ if arg is not None:
30
+ return arg
31
+ env_value = os.environ.get(CACHE_ENV_VAR)
32
+ if env_value:
33
+ return env_value
34
+ db_env = os.environ.get(DB_ENV_VAR)
35
+ if db_env:
36
+ return str(Path(db_env).resolve().parent / DEFAULT_CACHE_NAME)
37
+ return DEFAULT_CACHE_NAME
macrotrace/cli.py ADDED
@@ -0,0 +1,73 @@
1
+ """Top-level Macrotrace command-line interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from typing import List, Optional
8
+
9
+
10
+ def _run_ons_explorer(args: argparse.Namespace) -> int:
11
+ from macrotrace.ons_cli.cli import main as ons_main
12
+
13
+ return ons_main(args.args)
14
+
15
+
16
+ def _run_ons_tui(args: argparse.Namespace) -> int:
17
+ from macrotrace.ons_cli.tui import main as ons_tui_main
18
+
19
+ return ons_tui_main(args.args)
20
+
21
+
22
+ def build_parser() -> argparse.ArgumentParser:
23
+ parser = argparse.ArgumentParser(
24
+ prog="macrotrace",
25
+ description="Macrotrace command-line tools.",
26
+ )
27
+ subparsers = parser.add_subparsers(dest="command", required=True)
28
+
29
+ ons_parser = subparsers.add_parser(
30
+ "ons",
31
+ help="ONS explorer commands.",
32
+ description="ONS explorer commands.",
33
+ )
34
+ ons_subparsers = ons_parser.add_subparsers(dest="ons_command", required=True)
35
+
36
+ ons_subparsers.add_parser(
37
+ "explorer",
38
+ help="Run the interactive ONS explorer CLI.",
39
+ description="Run the interactive ONS explorer CLI.",
40
+ )
41
+ ons_subparsers.add_parser(
42
+ "tui",
43
+ help="Run the Textual ONS explorer TUI.",
44
+ description="Run the Textual ONS explorer TUI.",
45
+ )
46
+
47
+ return parser
48
+
49
+
50
+ def main(argv: Optional[List[str]] = None) -> int:
51
+ argv = list(sys.argv[1:] if argv is None else argv)
52
+ parser = build_parser()
53
+
54
+ if not argv:
55
+ parser.parse_args(argv)
56
+
57
+ if argv == ["--help"] or argv == ["-h"]:
58
+ parser.parse_args(argv)
59
+
60
+ if argv[0] != "ons":
61
+ parser.parse_args(argv)
62
+
63
+ if len(argv) == 1 or argv[1] in {"-h", "--help"}:
64
+ parser.parse_args(argv)
65
+
66
+ if argv[1] == "explorer":
67
+ return _run_ons_explorer(argparse.Namespace(args=argv[2:]))
68
+
69
+ if argv[1] == "tui":
70
+ return _run_ons_tui(argparse.Namespace(args=argv[2:]))
71
+
72
+ parser.parse_args(argv)
73
+ return 2
macrotrace/graphing.py ADDED
@@ -0,0 +1,183 @@
1
+ MACROTRACE_COLORWAY = [
2
+ "#4c72b0",
3
+ "#dd8452",
4
+ "#55a868",
5
+ "#c44e52",
6
+ "#8172b3",
7
+ "#937860",
8
+ "#da8bc3",
9
+ "#8c8c8c",
10
+ "#ccb974",
11
+ "#64b5cd",
12
+ ]
13
+
14
+ MACROTRACE_DIVERGING_COLORSCALE = [
15
+ [0.0, MACROTRACE_COLORWAY[0]],
16
+ [0.5, "#ffffff"],
17
+ [1.0, MACROTRACE_COLORWAY[1]],
18
+ ]
19
+
20
+ MACROTRACE_PLOTLY_LAYOUT_TEMPLATE = {
21
+ "layout": {
22
+ "annotationdefaults": {"arrowcolor": "#4368a7"},
23
+ "autotypenumbers": "strict",
24
+ "coloraxis": {
25
+ "colorbar": {
26
+ "outlinewidth": 0,
27
+ "tickcolor": "#242424",
28
+ "ticklen": 8,
29
+ "ticks": "outside",
30
+ "tickwidth": 2,
31
+ }
32
+ },
33
+ "colorscale": {
34
+ "diverging": MACROTRACE_DIVERGING_COLORSCALE,
35
+ "sequential": [
36
+ [0.0, "#020419"],
37
+ [0.06274509803921569, "#180f29"],
38
+ [0.12549019607843137, "#2f1739"],
39
+ [0.18823529411764706, "#471c48"],
40
+ [0.25098039215686274, "#611e52"],
41
+ [0.3137254901960784, "#7b1e59"],
42
+ [0.3764705882352941, "#961b5b"],
43
+ [0.4392156862745098, "#b11658"],
44
+ [0.5019607843137255, "#cb1a4f"],
45
+ [0.5647058823529412, "#df2f43"],
46
+ [0.6274509803921569, "#ec4c3d"],
47
+ [0.6901960784313725, "#f26b49"],
48
+ [0.7529411764705882, "#f4875f"],
49
+ [0.8156862745098039, "#f5a27a"],
50
+ [0.8784313725490196, "#f6bc99"],
51
+ [0.9411764705882353, "#f7d4bb"],
52
+ [1.0, "#faeadc"],
53
+ ],
54
+ "sequentialminus": [
55
+ [0.0, "#020419"],
56
+ [0.06274509803921569, "#180f29"],
57
+ [0.12549019607843137, "#2f1739"],
58
+ [0.18823529411764706, "#471c48"],
59
+ [0.25098039215686274, "#611e52"],
60
+ [0.3137254901960784, "#7b1e59"],
61
+ [0.3764705882352941, "#961b5b"],
62
+ [0.4392156862745098, "#b11658"],
63
+ [0.5019607843137255, "#cb1a4f"],
64
+ [0.5647058823529412, "#df2f43"],
65
+ [0.6274509803921569, "#ec4c3d"],
66
+ [0.6901960784313725, "#f26b49"],
67
+ [0.7529411764705882, "#f4875f"],
68
+ [0.8156862745098039, "#f5a27a"],
69
+ [0.8784313725490196, "#f6bc99"],
70
+ [0.9411764705882353, "#f7d4bb"],
71
+ [1.0, "#faeadc"],
72
+ ],
73
+ },
74
+ "colorway": MACROTRACE_COLORWAY,
75
+ "font": {
76
+ "color": "#242424",
77
+ "family": "CMU Serif, Old Standard TT, Georgia, Times New Roman, Times, serif",
78
+ },
79
+ "geo": {
80
+ "bgcolor": "white",
81
+ "lakecolor": "white",
82
+ "landcolor": "#d3efde",
83
+ "showlakes": True,
84
+ "showland": True,
85
+ "subunitcolor": "white",
86
+ },
87
+ "hoverlabel": {"align": "left"},
88
+ "hovermode": "closest",
89
+ "paper_bgcolor": "white",
90
+ "plot_bgcolor": "#eaeaf2",
91
+ "polar": {
92
+ "angularaxis": {
93
+ "gridcolor": "white",
94
+ "linecolor": "white",
95
+ "showgrid": True,
96
+ "ticks": "",
97
+ },
98
+ "bgcolor": "#eaeaf2",
99
+ "radialaxis": {
100
+ "gridcolor": "white",
101
+ "linecolor": "white",
102
+ "showgrid": True,
103
+ "ticks": "",
104
+ },
105
+ },
106
+ "scene": {
107
+ "xaxis": {
108
+ "backgroundcolor": "#eaeaf2",
109
+ "gridcolor": "white",
110
+ "gridwidth": 2,
111
+ "linecolor": "white",
112
+ "showbackground": True,
113
+ "showgrid": True,
114
+ "ticks": "",
115
+ "zerolinecolor": "white",
116
+ },
117
+ "yaxis": {
118
+ "backgroundcolor": "#eaeaf2",
119
+ "gridcolor": "white",
120
+ "gridwidth": 2,
121
+ "linecolor": "white",
122
+ "showbackground": True,
123
+ "showgrid": True,
124
+ "ticks": "",
125
+ "zerolinecolor": "white",
126
+ },
127
+ "zaxis": {
128
+ "backgroundcolor": "#eaeaf2",
129
+ "gridcolor": "white",
130
+ "gridwidth": 2,
131
+ "linecolor": "white",
132
+ "showbackground": True,
133
+ "showgrid": True,
134
+ "ticks": "",
135
+ "zerolinecolor": "white",
136
+ },
137
+ },
138
+ "shapedefaults": {
139
+ "fillcolor": "#4367a7",
140
+ "line": {"width": 3},
141
+ "opacity": 0.5,
142
+ },
143
+ "ternary": {
144
+ "aaxis": {
145
+ "gridcolor": "white",
146
+ "linecolor": "white",
147
+ "showgrid": True,
148
+ "ticks": "",
149
+ },
150
+ "baxis": {
151
+ "gridcolor": "white",
152
+ "linecolor": "white",
153
+ "showgrid": True,
154
+ "ticks": "",
155
+ },
156
+ "bgcolor": "#eaeaf2",
157
+ "caxis": {
158
+ "gridcolor": "white",
159
+ "linecolor": "white",
160
+ "showgrid": True,
161
+ "ticks": "",
162
+ },
163
+ },
164
+ "xaxis": {
165
+ "automargin": True,
166
+ "gridcolor": "white",
167
+ "linecolor": "white",
168
+ "showgrid": True,
169
+ "ticks": "",
170
+ "title": {"standoff": 15},
171
+ "zerolinecolor": "white",
172
+ },
173
+ "yaxis": {
174
+ "automargin": True,
175
+ "gridcolor": "white",
176
+ "linecolor": "white",
177
+ "showgrid": True,
178
+ "ticks": "",
179
+ "title": {"standoff": 15},
180
+ "zerolinecolor": "white",
181
+ },
182
+ }
183
+ }
@@ -0,0 +1,31 @@
1
+ from macrotrace.models.mt.observation import MTObservation
2
+ from macrotrace.models.mt.series_metadata import MTSeriesMetadata
3
+ from macrotrace.models.mt.time_series import MTTimeSeries
4
+ from macrotrace.models.mt.analysis import VintageComparison
5
+ from macrotrace.models.mt.plotter import MTTimeSeriesPlotter
6
+ from macrotrace.models.db import (
7
+ LOCAL_DATABASE,
8
+ Dataset,
9
+ DatasetDimension,
10
+ Release,
11
+ ReleaseDimension,
12
+ Series,
13
+ SeriesDimensionFilter,
14
+ Observation,
15
+ )
16
+
17
+ __all__ = [
18
+ "MTObservation",
19
+ "MTSeriesMetadata",
20
+ "MTTimeSeries",
21
+ "VintageComparison",
22
+ "MTTimeSeriesPlotter",
23
+ "LOCAL_DATABASE",
24
+ "Dataset",
25
+ "DatasetDimension",
26
+ "Release",
27
+ "ReleaseDimension",
28
+ "Series",
29
+ "SeriesDimensionFilter",
30
+ "Observation",
31
+ ]
@@ -0,0 +1,265 @@
1
+ import datetime
2
+ from peewee import (
3
+ SQL,
4
+ DateTimeField,
5
+ FloatField,
6
+ ForeignKeyField,
7
+ Model,
8
+ SqliteDatabase,
9
+ TextField,
10
+ )
11
+ from playhouse.sqlite_ext import JSONField
12
+ import pandas as pd
13
+
14
+ LOCAL_DATABASE = SqliteDatabase(
15
+ None,
16
+ pragmas=(
17
+ ("cache_size", -1024 * 32), # 32MB page-cache.
18
+ ("journal_mode", "wal"),
19
+ ("foreign_keys", 1),
20
+ ),
21
+ )
22
+
23
+
24
+ def is_valid_dateoffset(value: str) -> bool:
25
+ """
26
+ Checks if a string is a valid pandas date offset.
27
+ We are using this to ensure that frequency fields conform to pandas offset strings.
28
+ Certain export and processing functions depend on this format such as to_darts_timeseries.
29
+
30
+ Args:
31
+ value (str): The string to check.
32
+
33
+ Returns:
34
+ bool: True if the string is a valid pandas date offset, False otherwise.
35
+ """
36
+ try:
37
+ pd.tseries.frequencies.to_offset(value)
38
+ return True
39
+ except (ValueError, TypeError):
40
+ return False
41
+
42
+
43
+ class StrictDateTimeField(DateTimeField):
44
+ """DateTimeField that enforces timezone-aware datetime objects in ISO 8601 format."""
45
+
46
+ def db_value(self, value):
47
+ if value is None:
48
+ return None
49
+ if not isinstance(value, datetime.datetime):
50
+ raise ValueError(f"Value must be a datetime object, got {type(value)}")
51
+ if value.tzinfo is None:
52
+ raise ValueError("Datetime must be timezone-aware (include tzinfo).")
53
+ return value.isoformat()
54
+
55
+ def python_value(self, value):
56
+ if value is None:
57
+ return None
58
+ if isinstance(value, datetime.datetime):
59
+ return value
60
+ try:
61
+ # Parse ISO 8601 format
62
+ parsed = datetime.datetime.fromisoformat(value)
63
+ if parsed.tzinfo is None:
64
+ raise ValueError("Parsed datetime is missing timezone information.")
65
+ return parsed
66
+ except (ValueError, AttributeError) as e:
67
+ raise ValueError(
68
+ f"Invalid datetime format. Expected ISO 8601 with timezone: {e}"
69
+ )
70
+
71
+
72
+ class FrequencyField(TextField):
73
+ def db_value(self, value):
74
+ if value is not None and not is_valid_dateoffset(value):
75
+ raise ValueError(f"Invalid pandas frequency offset: {value}")
76
+ return super().db_value(value)
77
+
78
+
79
+ class DataBaseModel(Model):
80
+ class Meta:
81
+ database = LOCAL_DATABASE
82
+
83
+
84
+ class Dataset(DataBaseModel):
85
+ """Identity of a dataset; versioning lives in DatasetVersion."""
86
+
87
+ source = TextField()
88
+ dataset_id = TextField()
89
+
90
+ class Meta:
91
+ constraints = [SQL("UNIQUE(source, dataset_id)")]
92
+
93
+ def __repr__(self):
94
+ return f"Dataset(source={self.source}, dataset_id={self.dataset_id})"
95
+
96
+
97
+ class DatasetDimension(DataBaseModel):
98
+ """
99
+ Identity of a dataset dimension.
100
+ """
101
+
102
+ dataset = ForeignKeyField(
103
+ Dataset,
104
+ backref="dimensions",
105
+ on_delete="CASCADE",
106
+ )
107
+ # This is the ID used by the source to identify the dimension
108
+ # This should NOT change as the dimension is versioned via valid_from and valid_to
109
+ dataset_dimension_id = TextField()
110
+ title = TextField()
111
+ type = TextField(
112
+ choices=[
113
+ ("text", "text"),
114
+ ("numeric", "numeric"),
115
+ ("boolean", "boolean"),
116
+ ]
117
+ )
118
+ frequency = FrequencyField(null=True)
119
+ description = TextField(null=True)
120
+ units = TextField(null=True)
121
+ seasonal_adjustment = TextField(null=True)
122
+ # Validity period for this dimension definition, null valid_to means currently valid
123
+ valid_from = StrictDateTimeField()
124
+ valid_to = StrictDateTimeField(null=True)
125
+ created_at = StrictDateTimeField(
126
+ default=datetime.datetime.now(tz=datetime.timezone.utc)
127
+ )
128
+
129
+ class Meta:
130
+ constraints = [
131
+ SQL("UNIQUE(dataset_id, dataset_dimension_id, valid_from)"),
132
+ SQL("CHECK(type IN ('text', 'numeric', 'boolean'))"),
133
+ SQL("CHECK(valid_to IS NULL OR valid_to > valid_from)"),
134
+ ]
135
+
136
+ def __repr__(self):
137
+ return (
138
+ f"DatasetDimension(dataset={self.dataset.dataset_id}, title={self.title})"
139
+ )
140
+
141
+
142
+ class Release(DataBaseModel):
143
+ """
144
+ A release of data in a dataset at a point in time.
145
+ It is not versioned itself as releases are theoretically immutable once created.
146
+ """
147
+
148
+ dataset = ForeignKeyField(
149
+ Dataset,
150
+ backref="releases",
151
+ on_delete="CASCADE",
152
+ )
153
+ release_date = StrictDateTimeField()
154
+ additional_metadata = JSONField(null=True)
155
+ created_at = StrictDateTimeField(
156
+ default=datetime.datetime.now(tz=datetime.timezone.utc)
157
+ )
158
+
159
+ class Meta:
160
+ constraints = [SQL("UNIQUE(dataset_id, release_date)")]
161
+
162
+ def __repr__(self):
163
+ return (
164
+ f"Release(dataset={self.dataset.dataset_id}, "
165
+ f"release_date={self.release_date})"
166
+ )
167
+
168
+
169
+ class ReleaseDimension(DataBaseModel):
170
+ """
171
+ Association table for many-to-many relationship between Release and DatasetDimension.
172
+ Tracks which dimensions are included in each release.
173
+ """
174
+
175
+ release = ForeignKeyField(
176
+ Release,
177
+ backref="release_dimensions",
178
+ on_delete="CASCADE",
179
+ )
180
+ dimension = ForeignKeyField(
181
+ DatasetDimension,
182
+ backref="release_dimensions",
183
+ on_delete="CASCADE",
184
+ )
185
+ created_at = StrictDateTimeField(
186
+ default=datetime.datetime.now(tz=datetime.timezone.utc)
187
+ )
188
+
189
+ class Meta:
190
+ constraints = [SQL("UNIQUE(release_id, dimension_id)")]
191
+
192
+ def __repr__(self):
193
+ return (
194
+ f"ReleaseDimension(release={self.release.release_date}, "
195
+ f"dimension={self.dimension.title})"
196
+ )
197
+
198
+
199
+ class Series(DataBaseModel):
200
+ dataset = ForeignKeyField(Dataset, backref="series", on_delete="CASCADE")
201
+ series_key = JSONField()
202
+ created_at = StrictDateTimeField(
203
+ default=datetime.datetime.now(tz=datetime.timezone.utc)
204
+ )
205
+
206
+ def __repr__(self):
207
+ return (
208
+ f"Series(dataset = {self.dataset.dataset_id}, series_key={self.series_key})"
209
+ )
210
+
211
+
212
+ class SeriesDimensionFilter(DataBaseModel):
213
+ series = ForeignKeyField(
214
+ Series,
215
+ backref="dimension_selections",
216
+ on_delete="CASCADE",
217
+ )
218
+ dimension = ForeignKeyField(
219
+ DatasetDimension,
220
+ on_delete="CASCADE",
221
+ )
222
+ # For numeric or boolean dimensions
223
+ # Store as text, cast as needed based on dimension.type
224
+ value = TextField()
225
+
226
+ class Meta:
227
+ constraints = [
228
+ SQL("UNIQUE(series_id, dimension_id, value)"),
229
+ ]
230
+
231
+ def __repr__(self):
232
+ return (
233
+ f"SeriesDimensionFilter(series={self.series.series_key}, "
234
+ f"dimension={self.dimension.title}, value={self.value})"
235
+ )
236
+
237
+
238
+ class Observation(DataBaseModel):
239
+ series = ForeignKeyField(
240
+ Series,
241
+ backref="observations",
242
+ on_delete="CASCADE",
243
+ )
244
+ release = ForeignKeyField(
245
+ Release,
246
+ backref="observations",
247
+ on_delete="CASCADE",
248
+ )
249
+
250
+ observation_timestamp = StrictDateTimeField()
251
+ value = FloatField(null=True) # null if the observation is missing
252
+ created_at = StrictDateTimeField(
253
+ default=datetime.datetime.now(tz=datetime.timezone.utc)
254
+ )
255
+
256
+ class Meta:
257
+ constraints = [SQL("UNIQUE(release_id, observation_timestamp)")]
258
+
259
+ def __repr__(self):
260
+ return (
261
+ f"Observation(series={self.series.series_key}, "
262
+ f"release={self.release.release_date}, "
263
+ f"observation_timestamp={self.observation_timestamp}, "
264
+ f"value={self.value})"
265
+ )
@@ -0,0 +1,8 @@
1
+ """MacroTrace models for time series data."""
2
+
3
+ from macrotrace.models.mt.time_series import MTTimeSeries
4
+ from macrotrace.models.mt.analysis import VintageComparison
5
+ from macrotrace.models.mt.series_metadata import MTSeriesMetadata
6
+ from macrotrace.models.mt.observation import MTObservation
7
+
8
+ __all__ = ["MTTimeSeries", "VintageComparison", "MTSeriesMetadata", "MTObservation"]