eolas-data 1.0.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.
eolas_data/dataset.py ADDED
@@ -0,0 +1,46 @@
1
+ from __future__ import annotations
2
+
3
+ import pandas as pd
4
+
5
+
6
+ class Dataset(pd.DataFrame):
7
+ """A pandas DataFrame with eolas dataset metadata.
8
+
9
+ Behaves exactly like a DataFrame — all pandas operations work normally.
10
+ Extra attributes:
11
+ eolas_name: Dataset identifier (e.g. ``"nz_cpi"``).
12
+ eolas_source: Data source label (e.g. ``"Stats NZ"``).
13
+ """
14
+
15
+ _metadata = ["eolas_name", "eolas_source"]
16
+
17
+ @property
18
+ def _constructor(self):
19
+ return Dataset
20
+
21
+ def __repr__(self) -> str:
22
+ name = getattr(self, "eolas_name", "") or ""
23
+ source = getattr(self, "eolas_source", "") or ""
24
+ if name:
25
+ header = f"# Dataset: {name}"
26
+ if source:
27
+ header += f" [{source}]"
28
+ header += f"\n# {len(self)} rows\n"
29
+ return header + pd.DataFrame.__repr__(self)
30
+ return pd.DataFrame.__repr__(self)
31
+
32
+ # ------------------------------------------------------------------
33
+ # plot_dataset() was removed in v1.3.0.
34
+ #
35
+ # It auto-picked `date` and `value` columns and drew a single matplotlib
36
+ # line — but datasets with a dimension column (multiple series per date)
37
+ # produced silent zigzag traces. Rather than ship a helper that has to
38
+ # know each dataset's shape, plotting is now the caller's responsibility.
39
+ # `Dataset` subclasses `DataFrame`, so any matplotlib / seaborn / plotly
40
+ # workflow works straight out of the box:
41
+ #
42
+ # import matplotlib.pyplot as plt
43
+ # df.plot(x="date", y="value")
44
+ #
45
+ # See README for one-liners.
46
+ # ------------------------------------------------------------------
@@ -0,0 +1,20 @@
1
+ class EolasError(Exception):
2
+ """Base exception for the eolas-data client."""
3
+
4
+
5
+ class AuthenticationError(EolasError):
6
+ pass
7
+
8
+
9
+ class RateLimitError(EolasError):
10
+ pass
11
+
12
+
13
+ class NotFoundError(EolasError):
14
+ pass
15
+
16
+
17
+ class APIError(EolasError):
18
+ def __init__(self, status_code: int, message: str):
19
+ self.status_code = status_code
20
+ super().__init__(f"HTTP {status_code}: {message}")
eolas_data/schedule.py ADDED
@@ -0,0 +1,258 @@
1
+ """Cross-platform scheduling backend for `eolas schedule add|list|remove`.
2
+
3
+ POSIX (Linux/macOS): edits the user's crontab via `crontab -l` / `crontab -`.
4
+ Windows: uses `schtasks` to create per-user scheduled tasks.
5
+
6
+ Both backends only manage entries tagged with a sentinel so the user's other
7
+ cron jobs / scheduled tasks are never touched.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import csv
12
+ import io
13
+ import platform
14
+ import re
15
+ import shlex
16
+ import shutil
17
+ import subprocess
18
+ from dataclasses import dataclass
19
+ from typing import Optional
20
+
21
+ SENTINEL = "# eolas-schedule:"
22
+ TASK_PREFIX = "eolas-" # Windows task name prefix
23
+
24
+ # Interval shortcut → cron expression (minute hour dom month dow). Daily/weekly/
25
+ # monthly all default to 6am because datasets typically refresh in the early
26
+ # hours; running at 6am gets the freshest data without competing for resources.
27
+ INTERVALS = {
28
+ "hourly": "0 * * * *",
29
+ "daily": "0 6 * * *",
30
+ "weekly": "0 6 * * 1", # Monday 6am
31
+ "monthly": "0 6 1 * *", # 1st of month, 6am
32
+ }
33
+
34
+ # Windows schtasks /sc value per interval. Custom cron exprs not supported on
35
+ # Windows backend — see _windows_add for the fallback message.
36
+ WIN_SCHED = {
37
+ "hourly": ("HOURLY", None),
38
+ "daily": ("DAILY", "06:00"),
39
+ "weekly": ("WEEKLY", "06:00"), # default day = today's weekday; we override below
40
+ "monthly": ("MONTHLY", "06:00"),
41
+ }
42
+
43
+ CRON_EXPR_RE = re.compile(r"^\s*\S+\s+\S+\s+\S+\s+\S+\s+\S+\s*$")
44
+
45
+
46
+ @dataclass
47
+ class ScheduleEntry:
48
+ name: str
49
+ schedule: str # cron expr (POSIX) or human description (Windows)
50
+ command: str
51
+
52
+
53
+ # ────────────────────────────────────────────────────────────────────────────
54
+ # Public API — dispatches per OS
55
+ # ────────────────────────────────────────────────────────────────────────────
56
+
57
+ def is_windows() -> bool:
58
+ return platform.system() == "Windows"
59
+
60
+
61
+ def add(name: str, schedule_expr: str, command: str) -> None:
62
+ """Register a scheduled task. `schedule_expr` is a cron expression on POSIX
63
+ or one of {'hourly','daily','weekly','monthly'} on Windows."""
64
+ if is_windows():
65
+ _windows_add(name, schedule_expr, command)
66
+ else:
67
+ _cron_add(name, schedule_expr, command)
68
+
69
+
70
+ def remove(name: str) -> bool:
71
+ """Remove a managed task. Returns True if removed, False if not found."""
72
+ if is_windows():
73
+ return _windows_remove(name)
74
+ return _cron_remove(name)
75
+
76
+
77
+ def list_entries() -> list[ScheduleEntry]:
78
+ """Return all managed eolas-schedule entries."""
79
+ if is_windows():
80
+ return _windows_list()
81
+ return _cron_list()
82
+
83
+
84
+ def interval_to_cron(interval: str) -> str:
85
+ """Return the cron expression for an interval shortcut. Raises on unknown."""
86
+ if interval not in INTERVALS:
87
+ raise ValueError(f"unknown interval {interval!r}; expected one of {list(INTERVALS)}")
88
+ return INTERVALS[interval]
89
+
90
+
91
+ def validate_cron_expr(expr: str) -> None:
92
+ """Basic shape check on a 5-field cron expression. Raises on invalid."""
93
+ if not CRON_EXPR_RE.match(expr):
94
+ raise ValueError(
95
+ f"invalid cron expression {expr!r}; expected 5 fields "
96
+ "(minute hour day-of-month month day-of-week)"
97
+ )
98
+
99
+
100
+ # ────────────────────────────────────────────────────────────────────────────
101
+ # POSIX cron backend
102
+ # ────────────────────────────────────────────────────────────────────────────
103
+
104
+ def _crontab_available() -> bool:
105
+ return shutil.which("crontab") is not None
106
+
107
+
108
+ def _cron_read() -> list[str]:
109
+ """Read the user's crontab. Returns [] when no crontab is set."""
110
+ if not _crontab_available():
111
+ raise RuntimeError(
112
+ "crontab is not installed on this system. "
113
+ "On Debian/Ubuntu: sudo apt-get install cron. On Alpine: apk add busybox-suid."
114
+ )
115
+ proc = subprocess.run(
116
+ ["crontab", "-l"], capture_output=True, text=True
117
+ )
118
+ if proc.returncode == 0:
119
+ return proc.stdout.splitlines()
120
+ # Some implementations exit 1 with "no crontab" — treat as empty.
121
+ if "no crontab" in (proc.stderr or "").lower():
122
+ return []
123
+ raise RuntimeError(f"crontab -l failed: {proc.stderr.strip() or proc.stdout.strip()}")
124
+
125
+
126
+ def _cron_write(lines: list[str]) -> None:
127
+ payload = "\n".join(lines).rstrip() + "\n"
128
+ proc = subprocess.run(
129
+ ["crontab", "-"], input=payload, text=True, capture_output=True
130
+ )
131
+ if proc.returncode != 0:
132
+ raise RuntimeError(f"crontab - failed: {proc.stderr.strip()}")
133
+
134
+
135
+ def _cron_format_line(name: str, cron_expr: str, command: str) -> str:
136
+ return f"{cron_expr} {command} {SENTINEL} {name}"
137
+
138
+
139
+ def _cron_match_name(line: str, name: str) -> bool:
140
+ return SENTINEL in line and line.rstrip().endswith(name)
141
+
142
+
143
+ def _cron_add(name: str, cron_expr: str, command: str) -> None:
144
+ validate_cron_expr(cron_expr)
145
+ lines = [l for l in _cron_read() if not _cron_match_name(l, name)] # idempotent
146
+ lines.append(_cron_format_line(name, cron_expr, command))
147
+ _cron_write(lines)
148
+
149
+
150
+ def _cron_remove(name: str) -> bool:
151
+ lines = _cron_read()
152
+ kept = [l for l in lines if not _cron_match_name(l, name)]
153
+ if len(kept) == len(lines):
154
+ return False
155
+ _cron_write(kept)
156
+ return True
157
+
158
+
159
+ def _cron_list() -> list[ScheduleEntry]:
160
+ out: list[ScheduleEntry] = []
161
+ for line in _cron_read():
162
+ if SENTINEL not in line:
163
+ continue
164
+ head, _, tail = line.partition(SENTINEL)
165
+ name = tail.strip()
166
+ parts = head.strip().split(maxsplit=5)
167
+ if len(parts) < 6:
168
+ continue # malformed; skip silently
169
+ cron_expr = " ".join(parts[:5])
170
+ command = parts[5]
171
+ out.append(ScheduleEntry(name=name, schedule=cron_expr, command=command))
172
+ return out
173
+
174
+
175
+ # ────────────────────────────────────────────────────────────────────────────
176
+ # Windows schtasks backend
177
+ # ────────────────────────────────────────────────────────────────────────────
178
+
179
+ def _schtasks_available() -> bool:
180
+ return shutil.which("schtasks") is not None
181
+
182
+
183
+ def _windows_add(name: str, interval: str, command: str) -> None:
184
+ if not _schtasks_available():
185
+ raise RuntimeError("schtasks not found — required on Windows for scheduling")
186
+ if interval not in WIN_SCHED:
187
+ raise ValueError(
188
+ f"Windows backend supports interval shortcuts only "
189
+ f"({list(WIN_SCHED)}); got {interval!r}. "
190
+ "Custom cron expressions aren't translatable; use schtasks GUI for advanced cases."
191
+ )
192
+ sc, st = WIN_SCHED[interval]
193
+ args = [
194
+ "schtasks", "/create",
195
+ "/tn", f"{TASK_PREFIX}{name}",
196
+ "/tr", command,
197
+ "/sc", sc,
198
+ "/f", # overwrite if exists (idempotent add)
199
+ ]
200
+ if st:
201
+ args += ["/st", st]
202
+ if interval == "weekly":
203
+ args += ["/d", "MON"]
204
+ proc = subprocess.run(args, capture_output=True, text=True)
205
+ if proc.returncode != 0:
206
+ raise RuntimeError(f"schtasks /create failed: {proc.stderr.strip()}")
207
+
208
+
209
+ def _windows_remove(name: str) -> bool:
210
+ proc = subprocess.run(
211
+ ["schtasks", "/delete", "/tn", f"{TASK_PREFIX}{name}", "/f"],
212
+ capture_output=True, text=True,
213
+ )
214
+ if proc.returncode == 0:
215
+ return True
216
+ # schtasks returns non-zero if the task doesn't exist
217
+ if "cannot find" in (proc.stderr + proc.stdout).lower():
218
+ return False
219
+ raise RuntimeError(f"schtasks /delete failed: {proc.stderr.strip()}")
220
+
221
+
222
+ def _windows_list() -> list[ScheduleEntry]:
223
+ proc = subprocess.run(
224
+ ["schtasks", "/query", "/fo", "CSV", "/v"],
225
+ capture_output=True, text=True,
226
+ )
227
+ if proc.returncode != 0:
228
+ raise RuntimeError(f"schtasks /query failed: {proc.stderr.strip()}")
229
+ out: list[ScheduleEntry] = []
230
+ reader = csv.DictReader(io.StringIO(proc.stdout))
231
+ for row in reader:
232
+ task_name = (row.get("TaskName") or "").lstrip("\\").strip()
233
+ if not task_name.startswith(TASK_PREFIX):
234
+ continue
235
+ out.append(ScheduleEntry(
236
+ name=task_name[len(TASK_PREFIX):],
237
+ schedule=row.get("Schedule Type") or "",
238
+ command=row.get("Task To Run") or "",
239
+ ))
240
+ return out
241
+
242
+
243
+ # ────────────────────────────────────────────────────────────────────────────
244
+ # Helpers used by cli.py
245
+ # ────────────────────────────────────────────────────────────────────────────
246
+
247
+ def build_command(eolas_path: str, dataset: str, out_path: str,
248
+ start: Optional[str] = None, end: Optional[str] = None,
249
+ fmt: str = "csv") -> str:
250
+ """Construct the shell command line to put inside the cron entry."""
251
+ parts = [shlex.quote(eolas_path), "get", shlex.quote(dataset),
252
+ "--format", shlex.quote(fmt),
253
+ "--out", shlex.quote(str(out_path))]
254
+ if start:
255
+ parts += ["--start", shlex.quote(start)]
256
+ if end:
257
+ parts += ["--end", shlex.quote(end)]
258
+ return " ".join(parts)
@@ -0,0 +1,203 @@
1
+ Metadata-Version: 2.4
2
+ Name: eolas-data
3
+ Version: 1.0.0
4
+ Summary: Python client for the eolas.fyi statistical data API (NZ, Australia, OECD)
5
+ Project-URL: Homepage, https://eolas.fyi
6
+ Project-URL: Documentation, https://docs.eolas.fyi/
7
+ Project-URL: Repository, https://github.com/phildonovan/eolas-data
8
+ Project-URL: Bug Tracker, https://github.com/phildonovan/eolas-data/issues
9
+ Author-email: Virtus Solutions <phil@virtus-solutions.io>
10
+ License: MIT
11
+ Keywords: api,australia,economics,eolas,new-zealand,statistics
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: Science/Research
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Scientific/Engineering
22
+ Requires-Python: >=3.10
23
+ Requires-Dist: pandas>=1.5
24
+ Requires-Dist: requests>=2.28
25
+ Provides-Extra: cli
26
+ Requires-Dist: rich>=13; extra == 'cli'
27
+ Requires-Dist: typer>=0.12; extra == 'cli'
28
+ Provides-Extra: dev
29
+ Requires-Dist: geopandas>=0.14; extra == 'dev'
30
+ Requires-Dist: pandas; extra == 'dev'
31
+ Requires-Dist: pytest; extra == 'dev'
32
+ Requires-Dist: responses; extra == 'dev'
33
+ Requires-Dist: rich>=13; extra == 'dev'
34
+ Requires-Dist: shapely>=2.0; extra == 'dev'
35
+ Requires-Dist: typer>=0.12; extra == 'dev'
36
+ Provides-Extra: geo
37
+ Requires-Dist: geopandas>=0.14; extra == 'geo'
38
+ Requires-Dist: shapely>=2.0; extra == 'geo'
39
+ Provides-Extra: polars
40
+ Requires-Dist: polars>=0.20; extra == 'polars'
41
+ Description-Content-Type: text/markdown
42
+
43
+ # eolas-data
44
+
45
+ Python client for the [eolas.fyi](https://eolas.fyi) statistical data API — 717+ datasets across NZ, Australia, OECD, and more, served as tidy `pandas` DataFrames (or `polars` / `geopandas` if you prefer).
46
+
47
+ ```bash
48
+ pip install eolas-data
49
+ ```
50
+
51
+ ## Quickstart
52
+
53
+ ```python
54
+ from eolas_data import Client
55
+
56
+ client = Client("your_api_key") # or set EOLAS_API_KEY in env
57
+
58
+ # Generic
59
+ df = client.get("nz_cpi", start="2020-01-01")
60
+
61
+ # Source-specific (sets the `eolas_source` metadata)
62
+ df = client.statsnz("nz_cpi")
63
+ df = client.oecd("nz_gdp_production_annual")
64
+
65
+ # Discovery
66
+ all_datasets = client.list()
67
+ nz_only = client.list("Stats NZ")
68
+ meta = client.info("nz_cpi")
69
+ ```
70
+
71
+ Get an API key at <https://eolas.fyi/signup>. Free plan is 10 requests/month; Starter is 100; Pro is unlimited.
72
+
73
+ ## Command-line interface
74
+
75
+ `pip install eolas-data[cli]` adds an `eolas` command for browsing, fetching, and
76
+ scheduling — useful for shell scripts, cron jobs, and AI-agent workflows. Output
77
+ auto-detects piping: rich tables in a terminal, newline-delimited JSON when
78
+ stdout is piped.
79
+
80
+ ```bash
81
+ # one-time setup
82
+ eolas auth set-key
83
+ eolas health
84
+
85
+ # discover
86
+ eolas datasets list --source "Stats NZ"
87
+ eolas datasets list --search cpi --json | jq '.[].name'
88
+ eolas datasets info nz_cpi
89
+ eolas datasets preview nz_cpi --limit 5
90
+
91
+ # fetch (verb matches the Python lib's client.get())
92
+ eolas get nz_cpi --format csv > cpi.csv
93
+ eolas get nz_cpi --start 2020-01-01 --format json | jq '.[].value'
94
+ eolas get sa2_2023 --format parquet --out sa2.parquet
95
+ ```
96
+
97
+ ### Scheduling
98
+
99
+ Set up recurring fetches without touching crontab/Task Scheduler syntax. Works
100
+ on Linux, macOS (cron), and Windows (Task Scheduler).
101
+
102
+ ```bash
103
+ eolas schedule add nz_cpi --daily --out ~/data/cpi.csv
104
+ eolas schedule add nz_gdp --weekly --out ~/data/gdp.csv
105
+ eolas schedule add nzd_usd --cron "0 */6 * * *" --out ~/data/fx.csv # POSIX only
106
+
107
+ eolas schedule list
108
+ eolas schedule remove nz_cpi
109
+ ```
110
+
111
+ Daily is the default. Pre-flight check refuses to install a schedule unless
112
+ your API key is configured (otherwise the job would fail silently forever).
113
+
114
+ ### Integrations (Enterprise plan)
115
+
116
+ Generate ready-to-run connector configs for popular data-pipeline tools — eolas
117
+ becomes a one-command source for Meltano, Fivetran, or Azure Data Factory.
118
+
119
+ ```bash
120
+ eolas integrate meltano --datasets nz_cpi,nz_gdp --output ./my-pipeline/
121
+ eolas integrate fivetran --datasets nz_cpi
122
+ eolas integrate azure-data-factory --datasets nz_cpi,nz_gdp
123
+ ```
124
+
125
+ The generated directory has everything needed to plug into your destination
126
+ warehouse: `meltano.yml`, `fivetran.yml`, or ADF JSON resources, plus a `README.md`
127
+ walking through the rest of the setup. Non-Enterprise users see a clear
128
+ upgrade pointer; the gating lives server-side so the capability is bypass-proof.
129
+
130
+ ### Exit codes
131
+
132
+ Distinct exit codes per error class, for shell scripts and agents:
133
+
134
+ | Code | Meaning |
135
+ |---|---|
136
+ | `0` | Success |
137
+ | `1` | Generic error |
138
+ | `2` | Auth (`AuthenticationError`, including Enterprise-gate 403) |
139
+ | `3` | Rate limit hit |
140
+ | `4` | Dataset / resource not found |
141
+ | `5` | Other API error |
142
+ | `64` | Bad usage (mirrors `sysexits.h`) |
143
+
144
+ ## Geospatial
145
+
146
+ Datasets with a `geometry_wkt` column auto-convert to `geopandas.GeoDataFrame` if `geopandas` is installed:
147
+
148
+ ```bash
149
+ pip install eolas-data[geo]
150
+ ```
151
+
152
+ ```python
153
+ gdf = client.get("nz_addresses") # GeoDataFrame
154
+ df = client.get("nz_addresses", as_geo=False) # plain DataFrame, WKT preserved
155
+ ```
156
+
157
+ ## Polars
158
+
159
+ ```bash
160
+ pip install eolas-data[polars]
161
+ ```
162
+
163
+ ```python
164
+ df = client.get("nz_cpi", engine="polars")
165
+ ```
166
+
167
+ ## Plotting
168
+
169
+ `Dataset` is a `pandas.DataFrame` subclass — use matplotlib / seaborn / plotly
170
+ directly. No bundled plot helper, because there's no universal "right" plot for
171
+ a tidy dataset (single-series time series vs. wide multi-measure vs. WKT
172
+ geometry all need different code).
173
+
174
+ ```python
175
+ import matplotlib.pyplot as plt
176
+
177
+ df = client.statsnz("nz_cpi")
178
+ df.plot(x="date", y="value")
179
+ plt.show()
180
+ ```
181
+
182
+ ## Type stubs
183
+
184
+ Dataset names are exposed as a `Literal` so IDEs autocomplete the catalog:
185
+
186
+ ```python
187
+ from eolas_data import Client
188
+
189
+ client = Client()
190
+ client.get("nz_") # autocomplete shows nz_cpi, nz_gdp_production_annual, ...
191
+ ```
192
+
193
+ The list is regenerated from the live API at release time. Passing a name not in the snapshot still works at runtime — the type hint just won't autocomplete it. Catalog snapshot date is exposed as `eolas_data._dataset_names.CATALOG_SNAPSHOT_DATE`.
194
+
195
+ ## Releasing
196
+
197
+ See [`docs/clients.md`](https://github.com/phildonovan/eolas/blob/master/docs/clients.md) in the eolas data repo for the tagged-release flow and PyPI token rotation.
198
+
199
+ Before each release: `python -m eolas_data._regen_names` to refresh the dataset name stubs from the live API, commit the change, then tag and push.
200
+
201
+ ## License
202
+
203
+ MIT
@@ -0,0 +1,12 @@
1
+ eolas_data/__init__.py,sha256=xiONh2SG6TBa2IylGmqGNfTrdPUGYL9GaIuXD09v-oA,399
2
+ eolas_data/_dataset_names.py,sha256=YaVe6QaeRYh-2xfwQ1mPr2ZsSQRofIfr8rPMsetSiEk,95541
3
+ eolas_data/_regen_names.py,sha256=zpU3_C19DT8LZC09VZuVK_-D9tVGtPa5x8DLEO5flwM,1898
4
+ eolas_data/cli.py,sha256=Vz0R4hBGJlS59XajZi81LT6sxxpOUcAZP4grWpWwETg,27386
5
+ eolas_data/client.py,sha256=KlfEXrbC5cFu8QDgiAauTnKuMYCghBHCcYviHoA36jw,27928
6
+ eolas_data/dataset.py,sha256=XLoNCdA4AHaP-XjYKMQ6LRnAOh4sYyVuveUO5HjRbAw,1661
7
+ eolas_data/exceptions.py,sha256=AJXOgymkMFqQP4Q_zdfkkGs2lrOCUpB_1xbWRquGnec,404
8
+ eolas_data/schedule.py,sha256=3npQ9fWg7pU_Ll1wmsA-z3zLdMCXtQTcW-yF68-k__Q,10106
9
+ eolas_data-1.0.0.dist-info/METADATA,sha256=jAfIWR0xTYW4WS_jNTH9XeYpLV9kCei8oVU9J3irgLU,6571
10
+ eolas_data-1.0.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
11
+ eolas_data-1.0.0.dist-info/entry_points.txt,sha256=RKhA3k4kfBLH5S7Rvep2j6B_eKiEHlY70MXxJfvVNVo,45
12
+ eolas_data-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ eolas = eolas_data.cli:app