Skip to content

Commit b766f71

Browse files
committed
feat: add CLI, skill file, bump to 0.2.0, publish-on-push workflow
- CLI with typer for terminal access to EIA data - Claude Code skill file for AI-assisted usage - Bump version to 0.2.0 - Add push-to-main PyPI publish workflow with skip-existing
1 parent 6dd147f commit b766f71

14 files changed

Lines changed: 870 additions & 7 deletions

File tree

.github/workflows/publish.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,5 @@ jobs:
2424

2525
- name: Publish to PyPI
2626
uses: pypa/gh-action-pypi-publish@release/v1
27+
with:
28+
skip-existing: true

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@ docs
44
__pycache__
55
.ipynb_checkpoints
66
data
7-
sketch
7+
sketch
8+
dist/

eia/cli/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
"""EIA CLI — command-line interface for the EIA API."""
2+
3+
try:
4+
from eia.cli.app import app
5+
except ImportError:
6+
raise ImportError(
7+
"CLI dependencies not installed. Install with: pip install python-eia"
8+
)
9+
10+
__all__ = ["app"]

eia/cli/_output.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
"""Shared output helpers for the EIA CLI."""
2+
3+
from __future__ import annotations
4+
5+
from pathlib import Path
6+
from typing import Any
7+
8+
import pandas as pd
9+
import typer
10+
from rich.console import Console
11+
from rich.table import Table
12+
13+
console = Console()
14+
15+
16+
def render_dataframe(
17+
df: pd.DataFrame,
18+
format: str = "table",
19+
output: str | None = None,
20+
max_rows: int = 100,
21+
) -> None:
22+
"""Render a DataFrame in the requested format."""
23+
if format == "csv":
24+
text = df.to_csv(index=False)
25+
elif format == "json":
26+
text = df.to_json(orient="records", indent=2, date_format="iso")
27+
else:
28+
_print_rich_table(df, max_rows=max_rows)
29+
return
30+
31+
if output:
32+
Path(output).write_text(text)
33+
typer.echo(f"Written to {output}")
34+
else:
35+
typer.echo(text)
36+
37+
38+
def render_result(
39+
result: Any,
40+
format: str = "table",
41+
output: str | None = None,
42+
max_rows: int = 100,
43+
) -> None:
44+
"""Render an eval result (DataFrame, Series, or scalar)."""
45+
if isinstance(result, pd.Series):
46+
result = result.to_frame()
47+
48+
if isinstance(result, pd.DataFrame):
49+
if format == "csv":
50+
text = result.to_csv()
51+
elif format == "json":
52+
text = result.to_json(orient="records", indent=2, date_format="iso")
53+
else:
54+
_print_rich_table(result, max_rows=max_rows, show_index=True)
55+
return
56+
57+
if output:
58+
Path(output).write_text(text)
59+
typer.echo(f"Written to {output}")
60+
else:
61+
typer.echo(text)
62+
else:
63+
text = str(result)
64+
if output:
65+
Path(output).write_text(text)
66+
typer.echo(f"Written to {output}")
67+
else:
68+
typer.echo(text)
69+
70+
71+
def _print_rich_table(
72+
df: pd.DataFrame,
73+
max_rows: int = 100,
74+
show_index: bool = False,
75+
) -> None:
76+
"""Print a DataFrame as a Rich table."""
77+
table = Table()
78+
79+
if show_index:
80+
idx_name = str(df.index.name or "")
81+
table.add_column(idx_name, style="cyan")
82+
83+
for col in df.columns:
84+
table.add_column(str(col))
85+
86+
for idx, row in df.head(max_rows).iterrows():
87+
values = []
88+
if show_index:
89+
values.append(str(idx))
90+
values += [_fmt(row[c]) for c in df.columns]
91+
table.add_row(*values)
92+
93+
if len(df) > max_rows:
94+
table.caption = f"Showing {max_rows} of {len(df)} rows"
95+
96+
console.print(table)
97+
98+
99+
def _fmt(val: Any) -> str:
100+
"""Format a value for table display."""
101+
if isinstance(val, float):
102+
return f"{val:.4f}"
103+
if val is None:
104+
return ""
105+
return str(val)

eia/cli/app.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
"""EIA CLI — main Typer application."""
2+
3+
from __future__ import annotations
4+
5+
import logging
6+
7+
import typer
8+
9+
from eia.cli.config import get_api_key
10+
11+
# Suppress the library's default INFO logging in CLI mode
12+
logging.getLogger().setLevel(logging.WARNING)
13+
14+
app = typer.Typer(
15+
name="eia",
16+
help="CLI for the U.S. Energy Information Administration (EIA) API v2.",
17+
no_args_is_help=True,
18+
)
19+
20+
21+
def get_client(api_key: str | None = None):
22+
"""Lazy import + construct client."""
23+
from eia.client import EIAClient
24+
25+
resolved = api_key or get_api_key()
26+
if not resolved:
27+
typer.echo(
28+
"Error: No API key. Set EIA_API_KEY or run: eia config set api-key <KEY>",
29+
err=True,
30+
)
31+
raise typer.Exit(1)
32+
return EIAClient(api_key=resolved)
33+
34+
35+
# -- Register commands --------------------------------------------------------
36+
37+
from eia.cli.routes_cmd import routes_command # noqa: E402
38+
from eia.cli.meta_cmd import meta_command # noqa: E402
39+
from eia.cli.facets_cmd import facets_command # noqa: E402
40+
from eia.cli.get_cmd import get_command # noqa: E402
41+
from eia.cli.exec_cmd import exec_command # noqa: E402
42+
from eia.cli.config_cmd import config_app # noqa: E402
43+
44+
app.command(name="routes")(routes_command)
45+
app.command(name="meta")(meta_command)
46+
app.command(name="facets")(facets_command)
47+
app.command(name="get")(get_command)
48+
app.command(name="exec")(exec_command)
49+
app.add_typer(config_app, name="config", help="Configuration management")
50+
51+
52+
def main() -> None:
53+
app()
54+
55+
56+
if __name__ == "__main__":
57+
main()

eia/cli/config.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
"""Configuration management — read/write config.toml and env vars."""
2+
3+
from __future__ import annotations
4+
5+
import os
6+
from pathlib import Path
7+
8+
CONFIG_DIR = Path.home() / ".config" / "eia"
9+
CONFIG_FILE = CONFIG_DIR / "config.toml"
10+
11+
12+
def get_api_key() -> str | None:
13+
"""Resolve API key: config file > env var."""
14+
# Try config file first
15+
if CONFIG_FILE.exists():
16+
try:
17+
import tomllib
18+
except ImportError:
19+
import tomli as tomllib # type: ignore[no-redef]
20+
21+
with open(CONFIG_FILE, "rb") as f:
22+
config = tomllib.load(f)
23+
key = config.get("api-key")
24+
if key:
25+
return key
26+
27+
# Fall back to environment variable
28+
return os.getenv("EIA_API_KEY")
29+
30+
31+
def set_config(key: str, value: str) -> None:
32+
"""Write a key-value pair to config.toml."""
33+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
34+
35+
config: dict = {}
36+
if CONFIG_FILE.exists():
37+
try:
38+
import tomllib
39+
except ImportError:
40+
import tomli as tomllib # type: ignore[no-redef]
41+
42+
with open(CONFIG_FILE, "rb") as f:
43+
config = tomllib.load(f)
44+
45+
config[key] = value
46+
47+
# Write as simple TOML
48+
lines = [f'{k} = "{v}"' for k, v in config.items()]
49+
CONFIG_FILE.write_text("\n".join(lines) + "\n")
50+
51+
52+
def get_config(key: str) -> str | None:
53+
"""Read a value from config.toml."""
54+
if not CONFIG_FILE.exists():
55+
return None
56+
try:
57+
import tomllib
58+
except ImportError:
59+
import tomli as tomllib # type: ignore[no-redef]
60+
61+
with open(CONFIG_FILE, "rb") as f:
62+
config = tomllib.load(f)
63+
return config.get(key)

eia/cli/config_cmd.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
"""CLI subcommands for configuration management."""
2+
3+
from __future__ import annotations
4+
5+
import typer
6+
7+
config_app = typer.Typer(no_args_is_help=True)
8+
9+
10+
@config_app.command("set")
11+
def config_set(
12+
key: str = typer.Argument(..., help="Configuration key (e.g., 'api-key')"),
13+
value: str = typer.Argument(..., help="Configuration value"),
14+
):
15+
"""Set a configuration value."""
16+
from eia.cli.config import set_config
17+
18+
set_config(key, value)
19+
typer.echo(f"Set {key} = {'***' if 'key' in key.lower() else value}")
20+
21+
22+
@config_app.command("get")
23+
def config_get(
24+
key: str = typer.Argument(..., help="Configuration key to read"),
25+
):
26+
"""Get a configuration value."""
27+
from eia.cli.config import get_config
28+
29+
val = get_config(key)
30+
if val is None:
31+
typer.echo(f"{key}: (not set)")
32+
else:
33+
display = "***" if "key" in key.lower() else val
34+
typer.echo(f"{key} = {display}")

eia/cli/exec_cmd.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
"""CLI command: fetch data and evaluate a pandas expression."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Optional
6+
7+
import typer
8+
9+
from eia.cli._output import render_result
10+
from eia.cli.get_cmd import _parse_facets
11+
12+
13+
def exec_command(
14+
route: str = typer.Argument(..., help="Route path to a data endpoint"),
15+
start: Optional[str] = typer.Option(None, "--start", "-s", help="Start date (e.g. 2024-01-01)"),
16+
end: Optional[str] = typer.Option(None, "--end", "-e", help="End date (e.g. 2024-01-31)"),
17+
frequency: Optional[str] = typer.Option(None, "--frequency", help="Data frequency (e.g. hourly, monthly)"),
18+
facet: Optional[list[str]] = typer.Option(None, "--facet", help="Facet filter as key=value (repeatable)"),
19+
data: Optional[list[str]] = typer.Option(None, "--data", "-d", help="Data column to include (repeatable)"),
20+
expr: str = typer.Option("df", "--expr", "-x", help="Python expression to evaluate (df, pd, np available)"),
21+
format: str = typer.Option("table", "--format", "-f", help="Output format: table, csv, json"),
22+
output: Optional[str] = typer.Option(None, "--output", "-o", help="Output file path"),
23+
api_key: Optional[str] = typer.Option(None, "--api-key", "-k", help="EIA API key"),
24+
):
25+
"""Fetch data and evaluate a Python expression on it.
26+
27+
The fetched data is available as `df` (pandas DataFrame).
28+
`pd` (pandas) and `np` (numpy) are also available.
29+
30+
\b
31+
Examples:
32+
eia exec electricity/rto/fuel-type-data \\
33+
--start 2024-06-01 --end 2024-06-08 \\
34+
--frequency hourly \\
35+
--facet respondent=CISO --data value \\
36+
-x "df.groupby('fueltype')['value'].mean()"
37+
38+
eia exec electricity/rto/fuel-type-data \\
39+
--start 2024-06-01 --end 2024-06-03 \\
40+
--frequency hourly \\
41+
--facet respondent=CISO --data value \\
42+
-x "df.describe()"
43+
"""
44+
import numpy as np
45+
import pandas as pd
46+
47+
from eia.cli.app import get_client
48+
49+
client = get_client(api_key)
50+
51+
# Build the Data object
52+
data_endpoint = client.get_data_endpoint(route)
53+
54+
# Parse facets
55+
facets = _parse_facets(facet) if facet else None
56+
57+
# Fetch
58+
df = data_endpoint.get(
59+
data_columns=data or None,
60+
facets=facets,
61+
frequency=frequency,
62+
start=start,
63+
end=end,
64+
)
65+
66+
if df.empty:
67+
typer.echo("No data returned.")
68+
raise typer.Exit(0)
69+
70+
# Evaluate expression
71+
namespace = {"df": df, "pd": pd, "np": np}
72+
try:
73+
result = eval(expr, {"__builtins__": {}}, namespace) # noqa: S307
74+
except Exception as exc:
75+
typer.echo(f"Error evaluating expression: {exc}", err=True)
76+
raise typer.Exit(1)
77+
78+
render_result(result, format=format, output=output)

0 commit comments

Comments
 (0)