Skip to content

Commit 9c005ec

Browse files
committed
Packer tests, updated format, .env
* Updated tests * Added fetching a scenario directly in the Jupyter notebook to the example * Convert from yml settings to .env * Separated inputs and outputs into their own folders at root
1 parent e2d9488 commit 9c005ec

21 files changed

+3230
-171
lines changed

.gitignore

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,5 +18,6 @@ import_graph.svg
1818
tmp/
1919

2020
*.xlsx
21-
# Keep the example input
22-
!examples/example_input_excel.xlsx
21+
# Keep the examples
22+
!inputs/example_input_excel.xlsx
23+
!examples/example.config.env

README.md

Lines changed: 31 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ If you want development dependencies (testing, linting, etc.) then append the
8080

8181
#### How to use the environment:
8282
You can either:
83-
- Run commands inside Poetrys environment:
83+
- Run commands inside Poetry's environment:
8484
```bash
8585
poetry run pytest
8686
poetry run pyetm
@@ -97,25 +97,40 @@ You can either:
9797

9898
## Configuring Your Settings
9999

100-
You can configure your API token and base URL either with a **config.yml** file or environment variables. You can now simply set an `environment` and the base URL will be inferred for you.
100+
You can configure your API token and base URL either with a **config.env** file or environment variables. You can simply set an `environment` and the base URL will be inferred for you.
101101

102-
### Option 1: `config.yml`
103-
1. Duplicate the example file (`examples/example.config.yml`) and rename it to `config.yml`.
104-
2. Edit `config.yml`:
105-
- **etm_api_token**: Your ETM API token (overridden by `$ETM_API_TOKEN` if set).
106-
- **environment**: pro (default), beta, local, or a stable tag like `2025-01`. When set, `base_url` is inferred automatically.
107-
- (optional) **base_url**: API base URL (overridden by `$BASE_URL` if set). If both `environment` and `base_url` are set, `base_url` wins.
108-
Examples if you need a direct override:
109-
- `https://engine.energytransitionmodel.com/api/v3` (pro)
110-
- `https://beta.engine.energytransitionmodel.com/api/v3` (beta)
111-
- `https://2025-01.engine.energytransitionmodel.com/api/v3` (stable tag)
112-
- **proxy_servers**: (Optional) HTTP/HTTPS proxy URLs.
113-
- **csv_separator** and **decimal_separator**: Defaults are `,` and `.`.
102+
### Option 1: `config.env` (Recommended)
103+
1. Copy the example file (`example.config.env`) and rename it to `config.env`.
104+
2. Edit `config.env`:
105+
```bash
106+
# Your ETM API token (required)
107+
ETM_API_TOKEN=your.token.here
114108

115-
Place `config.yml` in the project root (`pyetm/` folder).
109+
# Environment (default: pro)
110+
ENVIRONMENT=pro
111+
112+
# Optional: Override base URL directly
113+
# BASE_URL=https://engine.energytransitionmodel.com/api/v3
114+
115+
# Optional: Proxy settings
116+
# PROXY_SERVERS_HTTP=http://user:[email protected]:8080
117+
# PROXY_SERVERS_HTTPS=http://user:[email protected]:8080
118+
119+
# CSV settings (optional)
120+
CSV_SEPARATOR=,
121+
DECIMAL_SEPARATOR=.
122+
```
123+
124+
Place `config.env` in the project root (`pyetm/` folder).
125+
126+
**Environment Options:**
127+
- `pro` (default): Production environment
128+
- `beta`: Staging environment
129+
- `local`: Local development environment
130+
- `YYYY-MM`: Stable tagged environment (e.g., `2025-01`)
116131

117132
### Option 2: Environment Variables
118-
If you prefer, set these environment variables:
133+
If you prefer, set these environment variables directly:
119134
```bash
120135
ETM_API_TOKEN=<your token>
121136
ENVIRONMENT=<pro|beta|local|YYYY-MM>

examples/create_or_query_scenarios.ipynb

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
"\n",
3232
"In the example, there are two scenarios, with short names scen_a and scen_b. You can use short names in the slider_settings sheet to specify which inputs belong to which scenario. Because scen_b has no scenario_id, it is being created. It will be created with all the metadata included in the sheet, plus any of the inputs under the column with its short_name and any sortables and curves specified in the sheets named beside the sortables and custom_curves rows. The same goes for scen_a, but because it has a scenario_id (1357395) that scenario will be loaded, and then updated with anything as set in the excel.\n",
3333
"\n",
34-
"**TODO**: Figure out how to manage the fact that for this example whatever scenario 1357395 is will be constantly updated etc by everyone who wants to try running this script on pro/beta. At the moment its just a local scenario."
34+
"The example scenario ids are scenarios on pro. It's recommended to change the scenario_ids in the example and experiment with scenarios you own."
3535
]
3636
},
3737
{
@@ -42,9 +42,14 @@
4242
"outputs": [],
4343
"source": [
4444
"from pyetm.models.scenarios import Scenarios\n",
45+
"from pyetm.models.scenario import Scenario\n",
46+
"\n",
4547
"\n",
4648
"scenarios = Scenarios.from_excel(\"example_input_excel.xlsx\")\n",
47-
"#scenario_a = Scenario.load(123456789) #TODO: Also load a scenario and include it in the array"
49+
"\n",
50+
"# Here we're also loading a scenario directly from the API and adding it to the scenarios loaded/created via the excel\n",
51+
"scenario_a = Scenario.load(1357691)\n",
52+
"scenarios.add(scenario_a)"
4853
]
4954
},
5055
{
@@ -84,7 +89,7 @@
8489
"source": [
8590
"# Inputs\n",
8691
"for scenario in scenarios:\n",
87-
" inputs = scenario.inputs.to_dataframe(columns=[\"user\", \"default\", \"min\", \"max\"]).head(20)\n",
92+
" inputs = scenario.inputs.to_dataframe(columns=[\"user\", \"default\", \"min\", \"max\"]).head(15)\n",
8893
" print(inputs)\n",
8994
" print(\"\")"
9095
]

examples/example.config.env

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# ETM API Configuration
2+
# Copy this file to .env and update with your personal settings
3+
# Never commit your .env file to version control!
4+
5+
# Your personal ETM API Token (REQUIRED)
6+
# Get your token from: https://docs.energytransitionmodel.com/api/authentication
7+
# Format: etm_<JWT> or etm_beta_<JWT>
8+
ETM_API_TOKEN=your.token.here
9+
10+
# ETM Environment (default: pro)
11+
# Options: pro, beta, local, or stable tags like 2025-01
12+
ENVIRONMENT=pro
13+
14+
# Override API base URL (optional - will be inferred from ENVIRONMENT if not set)
15+
# Examples:
16+
# BASE_URL=https://engine.energytransitionmodel.com/api/v3
17+
# BASE_URL=https://beta.engine.energytransitionmodel.com/api/v3
18+
# BASE_URL=http://localhost:3000/api/v3
19+
# BASE_URL=https://2025-01.engine.energytransitionmodel.com/api/v3
20+
# BASE_URL=
21+
22+
# Logging level (default: INFO)
23+
# Options: DEBUG, INFO, WARNING, ERROR, CRITICAL
24+
LOG_LEVEL=INFO
25+
26+
# Proxy Settings (optional)
27+
# Never commit authenticated proxy URLs to version control!
28+
# PROXY_SERVERS_HTTP=http://user:[email protected]:8080
29+
# PROXY_SERVERS_HTTPS=http://user:[email protected]:8080
30+
31+
# CSV File Settings
32+
# CSV separator character (default: ,)
33+
CSV_SEPARATOR=,
34+
35+
# Decimal separator character (default: .)
36+
DECIMAL_SEPARATOR=.

examples/example.config.yml

Lines changed: 0 additions & 40 deletions
This file was deleted.
File renamed without changes.

src/pyetm/config/settings.py

Lines changed: 53 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,31 @@
11
from pathlib import Path
22
import re
3-
import yaml, os
43
from typing import Optional, ClassVar, List, Annotated
54
from pydantic import Field, ValidationError, HttpUrl, field_validator
65
from pydantic_settings import BaseSettings, SettingsConfigDict
76

87
PROJECT_ROOT = Path(__file__).resolve().parents[3]
9-
CONFIG_FILE = PROJECT_ROOT / "config.yml"
8+
ENV_FILE = PROJECT_ROOT / "config.env"
109

1110

1211
class AppConfig(BaseSettings):
1312
"""
14-
Application configuration loaded from YAML.
13+
Application configuration loaded from .env file and environment variables.
1514
"""
1615

1716
etm_api_token: Annotated[
1817
str,
1918
Field(
2019
...,
21-
description="Your ETM API token: must be either `etm_<JWT>` or `etm_beta_<JWT>`. If not set please set $ETM_API_TOKEN or config.yml:etm_api_token",
20+
description="Your ETM API token: must be either `etm_<JWT>` or `etm_beta_<JWT>`",
2221
),
2322
]
24-
base_url: HttpUrl = Field(
25-
"https://engine.energytransitionmodel.com/api/v3",
26-
description="Base URL for the ETM API",
23+
base_url: Optional[HttpUrl] = Field(
24+
None,
25+
description="Base URL for the ETM API (will be inferred from environment if not provided)",
2726
)
2827
environment: Optional[str] = Field(
29-
None,
28+
"pro",
3029
description=(
3130
"ETM environment to target. One of: 'pro' (default), 'beta', 'local', or a stable tag 'YYYY-MM'. "
3231
"When set and base_url is not provided, base_url will be inferred."
@@ -37,12 +36,40 @@ class AppConfig(BaseSettings):
3736
description="App logging level",
3837
)
3938

39+
proxy_servers_http: Optional[str] = Field(
40+
None,
41+
description="HTTP proxy server URL",
42+
)
43+
proxy_servers_https: Optional[str] = Field(
44+
None,
45+
description="HTTPS proxy server URL",
46+
)
47+
csv_separator: str = Field(
48+
",",
49+
description="CSV file separator character",
50+
)
51+
decimal_separator: str = Field(
52+
".",
53+
description="Decimal separator character",
54+
)
55+
4056
model_config: ClassVar[SettingsConfigDict] = SettingsConfigDict(
41-
env_file=None, extra="ignore", case_sensitive=False
57+
case_sensitive=False,
58+
extra="ignore",
4259
)
4360

4461
temp_folder: Optional[Path] = PROJECT_ROOT / "tmp"
4562

63+
def __init__(self, **values):
64+
"""
65+
This ensures tests can monkeypatch `pyetm.config.settings.ENV_FILE`
66+
"""
67+
super().__init__(
68+
_env_file=ENV_FILE,
69+
_env_file_encoding="utf-8",
70+
**values,
71+
)
72+
4673
@field_validator("etm_api_token")
4774
@classmethod
4875
def check_jwt(cls, v: str) -> str:
@@ -77,43 +104,33 @@ def check_jwt(cls, v: str) -> str:
77104

78105
return v
79106

107+
def model_post_init(self, __context) -> None:
108+
"""Post-initialization to handle base_url inference."""
109+
if not self.base_url:
110+
self.base_url = HttpUrl(_infer_base_url_from_env(self.environment))
111+
80112
def path_to_tmp(self, subfolder: str):
81113
folder = self.temp_folder / subfolder
82114
folder.mkdir(parents=True, exist_ok=True)
83115
return folder
84116

85-
@classmethod
86-
def from_yaml(cls, path: Path) -> "AppConfig":
87-
raw = {}
88-
if path.is_file():
89-
try:
90-
raw = yaml.safe_load(path.read_text()) or {}
91-
except yaml.YAMLError:
92-
raw = {}
93-
94-
data = {k.lower(): v for k, v in raw.items()}
95-
96-
# Collect environment variables overriding YAML
97-
for field in ("etm_api_token", "base_url", "log_level", "environment"):
98-
if val := os.getenv(field.upper()):
99-
data[field] = val
100-
101-
# If base_url wasn't explicitly provided, infer it from environment if present
102-
if "base_url" not in data or not data["base_url"]:
103-
env = (data.get("environment") or "").strip().lower()
104-
if env:
105-
data["base_url"] = _infer_base_url_from_env(env)
106-
107-
return cls(**data)
117+
@property
118+
def proxy_servers(self) -> dict[str, str]:
119+
"""Return proxy servers as a dictionary for backward compatibility."""
120+
proxies = {}
121+
if self.proxy_servers_http:
122+
proxies["http"] = self.proxy_servers_http
123+
if self.proxy_servers_https:
124+
proxies["https"] = self.proxy_servers_https
125+
return proxies
108126

109127

110128
def get_settings() -> AppConfig:
111129
"""
112-
Always re-load AppConfig from disk and ENV on each call,
113-
and raise a clear, aggregated message if anything required is missing.
130+
Load AppConfig from .env file and environment variables.
114131
"""
115132
try:
116-
return AppConfig.from_yaml(CONFIG_FILE)
133+
return AppConfig()
117134
except ValidationError as exc:
118135
missing_or_invalid: List[str] = []
119136
for err in exc.errors():
@@ -125,7 +142,7 @@ def get_settings() -> AppConfig:
125142
raise RuntimeError(
126143
f"\nConfiguration error: one or more required settings are missing or invalid:\n\n"
127144
f"{detail}\n\n"
128-
f"Please set them via environment variables or in `{CONFIG_FILE}`."
145+
f"Please set them via environment variables or in `{ENV_FILE}`."
129146
) from exc
130147

131148

src/pyetm/models/custom_curves.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,14 @@ def from_json(cls, data: dict) -> CustomCurve:
120120
Initialize a CustomCurve from JSON data
121121
"""
122122
try:
123-
curve = cls.model_validate(data)
123+
curve = cls(**data)
124+
missing = [k for k in ("key", "type") if k not in data]
125+
if missing:
126+
curve.add_warning(
127+
"base",
128+
f"Failed to create curve from data: missing required fields: {', '.join(missing)}",
129+
)
130+
124131
return curve
125132
except Exception as e:
126133
basic_data = {

src/pyetm/models/scenario.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22
import pandas as pd
33
from datetime import datetime
4+
from pathlib import Path
45
from typing import Any, Dict, List, Optional, Set, Union
56
from urllib.parse import urlparse
67
from pydantic import Field, PrivateAttr
@@ -108,8 +109,12 @@ def from_excel(cls, xlsx_path: PathLike | str) -> List["Scenario"]:
108109
Load or create one or more scenarios from an Excel workbook.
109110
"""
110111
from pyetm.models.scenario_packer import ScenarioPacker
112+
from pyetm.utils.paths import PyetmPaths
111113

112-
packer = ScenarioPacker.from_excel(xlsx_path)
114+
resolver = PyetmPaths()
115+
path = resolver.resolve_for_read(xlsx_path, default_dir="inputs")
116+
117+
packer = ScenarioPacker.from_excel(str(path))
113118
scenarios = list(packer._scenarios())
114119
scenarios.sort(key=lambda s: s.id)
115120
return scenarios
@@ -130,10 +135,15 @@ def to_excel(
130135
Output curves are exported to a separate workbook only when enabled, with one
131136
sheet per carrier. Use carriers to filter which carriers to include when exporting.
132137
"""
138+
133139
from pyetm.models.scenarios import Scenarios
140+
from pyetm.utils.paths import PyetmPaths
141+
142+
resolver = PyetmPaths()
143+
out_path = resolver.resolve_for_write(path, default_dir="outputs")
134144

135145
Scenarios(items=[self, *others]).to_excel(
136-
path,
146+
str(out_path),
137147
carriers=carriers,
138148
include_inputs=include_inputs,
139149
include_sortables=include_sortables,

0 commit comments

Comments
 (0)