greenpick 0.1.4__tar.gz → 0.1.6__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.
- {greenpick-0.1.4/src/greenpick.egg-info → greenpick-0.1.6}/PKG-INFO +30 -6
- {greenpick-0.1.4 → greenpick-0.1.6}/README.md +29 -5
- {greenpick-0.1.4 → greenpick-0.1.6}/pyproject.toml +1 -1
- greenpick-0.1.6/src/greenpick/__init__.py +15 -0
- greenpick-0.1.6/src/greenpick/data/_net.py +23 -0
- {greenpick-0.1.4 → greenpick-0.1.6}/src/greenpick/data/eia_repository.py +7 -3
- {greenpick-0.1.4 → greenpick-0.1.6}/src/greenpick/data/electricitymaps_repository.py +9 -4
- {greenpick-0.1.4 → greenpick-0.1.6}/src/greenpick/data/entsoe_repository.py +53 -40
- {greenpick-0.1.4 → greenpick-0.1.6}/src/greenpick/server.py +23 -1
- {greenpick-0.1.4 → greenpick-0.1.6/src/greenpick.egg-info}/PKG-INFO +30 -6
- {greenpick-0.1.4 → greenpick-0.1.6}/src/greenpick.egg-info/SOURCES.txt +1 -0
- {greenpick-0.1.4 → greenpick-0.1.6}/tests/test_services.py +108 -0
- greenpick-0.1.4/src/greenpick/__init__.py +0 -7
- {greenpick-0.1.4 → greenpick-0.1.6}/LICENSE +0 -0
- {greenpick-0.1.4 → greenpick-0.1.6}/setup.cfg +0 -0
- {greenpick-0.1.4 → greenpick-0.1.6}/src/greenpick/__main__.py +0 -0
- {greenpick-0.1.4 → greenpick-0.1.6}/src/greenpick/config.py +0 -0
- {greenpick-0.1.4 → greenpick-0.1.6}/src/greenpick/data/__init__.py +0 -0
- {greenpick-0.1.4 → greenpick-0.1.6}/src/greenpick/data/auto_repository.py +0 -0
- {greenpick-0.1.4 → greenpick-0.1.6}/src/greenpick/data/emission_factors.json +0 -0
- {greenpick-0.1.4 → greenpick-0.1.6}/src/greenpick/data/gpu_power.json +0 -0
- {greenpick-0.1.4 → greenpick-0.1.6}/src/greenpick/data/inference_energy.json +0 -0
- {greenpick-0.1.4 → greenpick-0.1.6}/src/greenpick/data/instance_families.json +0 -0
- {greenpick-0.1.4 → greenpick-0.1.6}/src/greenpick/data/mock_repository.py +0 -0
- {greenpick-0.1.4 → greenpick-0.1.6}/src/greenpick/data/network.json +0 -0
- {greenpick-0.1.4 → greenpick-0.1.6}/src/greenpick/data/power.json +0 -0
- {greenpick-0.1.4 → greenpick-0.1.6}/src/greenpick/data/pricing.json +0 -0
- {greenpick-0.1.4 → greenpick-0.1.6}/src/greenpick/data/regions.json +0 -0
- {greenpick-0.1.4 → greenpick-0.1.6}/src/greenpick/data/repository.py +0 -0
- {greenpick-0.1.4 → greenpick-0.1.6}/src/greenpick/data/static_data.py +0 -0
- {greenpick-0.1.4 → greenpick-0.1.6}/src/greenpick/models.py +0 -0
- {greenpick-0.1.4 → greenpick-0.1.6}/src/greenpick/services/__init__.py +0 -0
- {greenpick-0.1.4 → greenpick-0.1.6}/src/greenpick/services/carbon_service.py +0 -0
- {greenpick-0.1.4 → greenpick-0.1.6}/src/greenpick/services/cost_service.py +0 -0
- {greenpick-0.1.4 → greenpick-0.1.6}/src/greenpick/services/forecast_service.py +0 -0
- {greenpick-0.1.4 → greenpick-0.1.6}/src/greenpick/services/inference_service.py +0 -0
- {greenpick-0.1.4 → greenpick-0.1.6}/src/greenpick/services/instance_service.py +0 -0
- {greenpick-0.1.4 → greenpick-0.1.6}/src/greenpick/services/latency_service.py +0 -0
- {greenpick-0.1.4 → greenpick-0.1.6}/src/greenpick/services/locality_service.py +0 -0
- {greenpick-0.1.4 → greenpick-0.1.6}/src/greenpick/services/metric_scaffold_service.py +0 -0
- {greenpick-0.1.4 → greenpick-0.1.6}/src/greenpick/services/recommender.py +0 -0
- {greenpick-0.1.4 → greenpick-0.1.6}/src/greenpick/services/residency_service.py +0 -0
- {greenpick-0.1.4 → greenpick-0.1.6}/src/greenpick/services/rightsizing_service.py +0 -0
- {greenpick-0.1.4 → greenpick-0.1.6}/src/greenpick/services/scheduling_service.py +0 -0
- {greenpick-0.1.4 → greenpick-0.1.6}/src/greenpick/services/serving_service.py +0 -0
- {greenpick-0.1.4 → greenpick-0.1.6}/src/greenpick/services/training_service.py +0 -0
- {greenpick-0.1.4 → greenpick-0.1.6}/src/greenpick.egg-info/dependency_links.txt +0 -0
- {greenpick-0.1.4 → greenpick-0.1.6}/src/greenpick.egg-info/entry_points.txt +0 -0
- {greenpick-0.1.4 → greenpick-0.1.6}/src/greenpick.egg-info/requires.txt +0 -0
- {greenpick-0.1.4 → greenpick-0.1.6}/src/greenpick.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: greenpick
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.6
|
|
4
4
|
Summary: Design-time sustainability co-pilot (MCP server): greener, cheaper cloud infrastructure decisions with carbon + cost + latency tradeoffs.
|
|
5
5
|
Author-email: "Data Science Advisers, LLC" <info@datascienceadvisers.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -254,15 +254,39 @@ of the options below.)
|
|
|
254
254
|
}
|
|
255
255
|
```
|
|
256
256
|
|
|
257
|
-
**In a Jupyter notebook or Python script**,
|
|
257
|
+
**In a Jupyter notebook or Python script**, connect with the MCP client library and pass the keys
|
|
258
|
+
in `env`. The library starts GreenPick as a separate process and doesn't forward the notebook's own
|
|
259
|
+
environment variables to it:
|
|
258
260
|
|
|
259
261
|
```python
|
|
260
|
-
import
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
262
|
+
import sys
|
|
263
|
+
from mcp import ClientSession, StdioServerParameters
|
|
264
|
+
from mcp.client.stdio import stdio_client
|
|
265
|
+
|
|
266
|
+
params = StdioServerParameters(
|
|
267
|
+
command=sys.executable,
|
|
268
|
+
args=["-m", "greenpick"],
|
|
269
|
+
env={
|
|
270
|
+
"GREENPICK_CARBON_SOURCE": "auto",
|
|
271
|
+
"EIA_API_KEY": "your-eia-key",
|
|
272
|
+
"ENTSOE_TOKEN": "your-entsoe-token",
|
|
273
|
+
},
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
# In Jupyter, errlog must be a real file (the notebook's stderr has no fileno()).
|
|
277
|
+
with open("greenpick-server.log", "w") as errlog:
|
|
278
|
+
async with stdio_client(params, errlog=errlog) as (read, write):
|
|
279
|
+
async with ClientSession(read, write) as session:
|
|
280
|
+
await session.initialize()
|
|
281
|
+
result = await session.call_tool(
|
|
282
|
+
"region_carbon_now", {"provider": "aws", "region": "us-west-2"}
|
|
283
|
+
)
|
|
284
|
+
print(result.content[0].text)
|
|
264
285
|
```
|
|
265
286
|
|
|
287
|
+
Top-level `await` works in Jupyter. In a plain script, put the `with` block inside an `async def`
|
|
288
|
+
and run it with `asyncio.run(...)`.
|
|
289
|
+
|
|
266
290
|
Every carbon figure includes a `quality` field: `live` means real grid data, `mock` means a
|
|
267
291
|
fallback value. Check it to confirm live data is on.
|
|
268
292
|
|
|
@@ -224,15 +224,39 @@ of the options below.)
|
|
|
224
224
|
}
|
|
225
225
|
```
|
|
226
226
|
|
|
227
|
-
**In a Jupyter notebook or Python script**,
|
|
227
|
+
**In a Jupyter notebook or Python script**, connect with the MCP client library and pass the keys
|
|
228
|
+
in `env`. The library starts GreenPick as a separate process and doesn't forward the notebook's own
|
|
229
|
+
environment variables to it:
|
|
228
230
|
|
|
229
231
|
```python
|
|
230
|
-
import
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
232
|
+
import sys
|
|
233
|
+
from mcp import ClientSession, StdioServerParameters
|
|
234
|
+
from mcp.client.stdio import stdio_client
|
|
235
|
+
|
|
236
|
+
params = StdioServerParameters(
|
|
237
|
+
command=sys.executable,
|
|
238
|
+
args=["-m", "greenpick"],
|
|
239
|
+
env={
|
|
240
|
+
"GREENPICK_CARBON_SOURCE": "auto",
|
|
241
|
+
"EIA_API_KEY": "your-eia-key",
|
|
242
|
+
"ENTSOE_TOKEN": "your-entsoe-token",
|
|
243
|
+
},
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
# In Jupyter, errlog must be a real file (the notebook's stderr has no fileno()).
|
|
247
|
+
with open("greenpick-server.log", "w") as errlog:
|
|
248
|
+
async with stdio_client(params, errlog=errlog) as (read, write):
|
|
249
|
+
async with ClientSession(read, write) as session:
|
|
250
|
+
await session.initialize()
|
|
251
|
+
result = await session.call_tool(
|
|
252
|
+
"region_carbon_now", {"provider": "aws", "region": "us-west-2"}
|
|
253
|
+
)
|
|
254
|
+
print(result.content[0].text)
|
|
234
255
|
```
|
|
235
256
|
|
|
257
|
+
Top-level `await` works in Jupyter. In a plain script, put the `with` block inside an `async def`
|
|
258
|
+
and run it with `asyncio.run(...)`.
|
|
259
|
+
|
|
236
260
|
Every carbon figure includes a `quality` field: `live` means real grid data, `mock` means a
|
|
237
261
|
fallback value. Check it to confirm live data is on.
|
|
238
262
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "greenpick"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.6"
|
|
4
4
|
description = "Design-time sustainability co-pilot (MCP server): greener, cheaper cloud infrastructure decisions with carbon + cost + latency tradeoffs."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.11"
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""GreenPick — design-time sustainability co-pilot (MCP server).
|
|
2
|
+
|
|
3
|
+
Greener cloud-region choices with honest carbon + cost + latency tradeoffs,
|
|
4
|
+
delivered inside the vibe-coding loop. Directional guidance, not audited accounting.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
__version__ = "0.1.6"
|
|
8
|
+
|
|
9
|
+
# httpx logs every request URL at INFO, and our live sources pass their API key / token as a URL
|
|
10
|
+
# query parameter — so at the default log level the secret would land in the MCP client's logs.
|
|
11
|
+
# Keep the HTTP libraries at WARNING (no request lines) for every entry point that imports us.
|
|
12
|
+
import logging as _logging
|
|
13
|
+
|
|
14
|
+
for _name in ("httpx", "httpcore"):
|
|
15
|
+
_logging.getLogger(_name).setLevel(_logging.WARNING)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Shared networking helpers for the live carbon sources.
|
|
2
|
+
|
|
3
|
+
Two jobs:
|
|
4
|
+
- `describe_error`: a log-safe summary of a failed request. httpx exception messages embed the
|
|
5
|
+
full request URL, and our data sources pass their API key / token as a URL query parameter, so
|
|
6
|
+
logging the exception verbatim would write the secret into the MCP client's log files.
|
|
7
|
+
- Shared timeouts, so one slow upstream can't freeze a tool call.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
# Per-step limits for a single HTTP request (connect / read / write / pool).
|
|
14
|
+
REQUEST_TIMEOUT = httpx.Timeout(8.0, connect=5.0)
|
|
15
|
+
# Hard ceiling for ONE request end-to-end. httpx timeouts are per-step, so a server that trickles
|
|
16
|
+
# bytes can stretch a request far past them; asyncio.wait_for enforces a true wall-clock limit.
|
|
17
|
+
REQUEST_DEADLINE_S = 15.0
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def describe_error(e: BaseException) -> str:
|
|
21
|
+
"""Error type plus HTTP status (if any) — never the URL, which carries the credential."""
|
|
22
|
+
status = getattr(getattr(e, "response", None), "status_code", None)
|
|
23
|
+
return f"{type(e).__name__}" + (f" (HTTP {status})" if status else "")
|
|
@@ -14,6 +14,7 @@ Carbon intensity = sum(gen_fuel * factor_fuel) / sum(gen_fuel). Directional, no
|
|
|
14
14
|
"""
|
|
15
15
|
from __future__ import annotations
|
|
16
16
|
|
|
17
|
+
import asyncio
|
|
17
18
|
import logging
|
|
18
19
|
import time
|
|
19
20
|
from datetime import datetime, timezone
|
|
@@ -23,6 +24,7 @@ import httpx
|
|
|
23
24
|
from ..models import CarbonReading, Provider, HourlyCarbon
|
|
24
25
|
from .mock_repository import MockCarbonRepository
|
|
25
26
|
from .static_data import region_meta, emission_factors
|
|
27
|
+
from ._net import REQUEST_TIMEOUT, REQUEST_DEADLINE_S, describe_error
|
|
26
28
|
|
|
27
29
|
log = logging.getLogger("greenpick.eia")
|
|
28
30
|
|
|
@@ -78,8 +80,9 @@ class EIARepository:
|
|
|
78
80
|
"sort[0][direction]": "desc",
|
|
79
81
|
"length": "2000", # ~5-7 days of hourly rows x fuels — enough for a daily pattern
|
|
80
82
|
}
|
|
81
|
-
async with httpx.AsyncClient(timeout=
|
|
82
|
-
resp = await client.get(_URL, params=params)
|
|
83
|
+
async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT) as client:
|
|
84
|
+
resp = await asyncio.wait_for(client.get(_URL, params=params),
|
|
85
|
+
timeout=REQUEST_DEADLINE_S)
|
|
83
86
|
if resp.status_code in (401, 403):
|
|
84
87
|
log.warning("EIA rejected the key (HTTP %s) — using mock. Check EIA_API_KEY.",
|
|
85
88
|
resp.status_code)
|
|
@@ -87,7 +90,8 @@ class EIARepository:
|
|
|
87
90
|
resp.raise_for_status()
|
|
88
91
|
return resp.json().get("response", {}).get("data", [])
|
|
89
92
|
except Exception as e:
|
|
90
|
-
|
|
93
|
+
# describe_error, not e: the exception text embeds the URL, which carries the key.
|
|
94
|
+
log.debug("EIA call failed for BA %s (%s) — mock fallback", ba, describe_error(e))
|
|
91
95
|
return None
|
|
92
96
|
|
|
93
97
|
async def carbon_history(self, provider: Provider, region: str) -> list[HourlyCarbon]:
|
|
@@ -13,6 +13,7 @@ API: GET https://api.electricitymap.org/v3/carbon-intensity/latest?zone=XX
|
|
|
13
13
|
"""
|
|
14
14
|
from __future__ import annotations
|
|
15
15
|
|
|
16
|
+
import asyncio
|
|
16
17
|
import logging
|
|
17
18
|
import time
|
|
18
19
|
from datetime import datetime, timezone
|
|
@@ -22,6 +23,7 @@ import httpx
|
|
|
22
23
|
from ..models import CarbonReading, Provider, HourlyCarbon
|
|
23
24
|
from .mock_repository import MockCarbonRepository
|
|
24
25
|
from .static_data import region_meta
|
|
26
|
+
from ._net import REQUEST_TIMEOUT, REQUEST_DEADLINE_S, describe_error
|
|
25
27
|
|
|
26
28
|
log = logging.getLogger("greenpick.electricitymaps")
|
|
27
29
|
|
|
@@ -66,9 +68,11 @@ class ElectricityMapsRepository:
|
|
|
66
68
|
})
|
|
67
69
|
|
|
68
70
|
try:
|
|
69
|
-
async with httpx.AsyncClient(timeout=
|
|
70
|
-
resp = await
|
|
71
|
-
|
|
71
|
+
async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT) as client:
|
|
72
|
+
resp = await asyncio.wait_for(
|
|
73
|
+
client.get(_BASE, params={"zone": zone}, headers={"auth-token": self._token}),
|
|
74
|
+
timeout=REQUEST_DEADLINE_S,
|
|
75
|
+
)
|
|
72
76
|
|
|
73
77
|
if resp.status_code in (401, 403):
|
|
74
78
|
# Config problem, not a per-region issue. Warn once, then stop trying.
|
|
@@ -100,5 +104,6 @@ class ElectricityMapsRepository:
|
|
|
100
104
|
|
|
101
105
|
except Exception as e:
|
|
102
106
|
# Network/parse/rate-limit -> mock fallback rather than erroring the tool.
|
|
103
|
-
log.debug("Electricity Maps call failed for zone %s (%s) — mock fallback",
|
|
107
|
+
log.debug("Electricity Maps call failed for zone %s (%s) — mock fallback",
|
|
108
|
+
zone, describe_error(e))
|
|
104
109
|
return await self._fallback.carbon_now(provider, region)
|
|
@@ -14,6 +14,7 @@ Response: XML with <TimeSeries> per production type:
|
|
|
14
14
|
"""
|
|
15
15
|
from __future__ import annotations
|
|
16
16
|
|
|
17
|
+
import asyncio
|
|
17
18
|
import logging
|
|
18
19
|
import re
|
|
19
20
|
import time
|
|
@@ -25,11 +26,17 @@ import httpx
|
|
|
25
26
|
from ..models import CarbonReading, Provider, HourlyCarbon, ForecastHour
|
|
26
27
|
from .mock_repository import MockCarbonRepository
|
|
27
28
|
from .static_data import region_meta, emission_factors
|
|
29
|
+
from ._net import REQUEST_TIMEOUT, REQUEST_DEADLINE_S, describe_error
|
|
28
30
|
|
|
29
31
|
log = logging.getLogger("greenpick.entsoe")
|
|
30
32
|
|
|
31
33
|
_URL = "https://web-api.tp.entsoe.eu/api"
|
|
32
34
|
|
|
35
|
+
# Retry policy for a slow/flaky upstream, bounded so a tool call never hangs on it:
|
|
36
|
+
_ATTEMPTS = 3
|
|
37
|
+
_FETCH_BUDGET_S = 20.0 # total wall-clock for ALL attempts + backoff of one fetch
|
|
38
|
+
_OUTAGE_COOLDOWN_S = 300.0 # after a failed fetch, skip that zone (labelled fallback) this long
|
|
39
|
+
|
|
33
40
|
|
|
34
41
|
def _strip_ns(tag: str) -> str:
|
|
35
42
|
"""ENTSO-E XML is namespaced; strip '{ns}tag' -> 'tag' for simple matching."""
|
|
@@ -70,6 +77,7 @@ class ENTSOERepository:
|
|
|
70
77
|
self._cache: dict[str, tuple[float, CarbonReading]] = {} # EIC -> (fetched_at, reading)
|
|
71
78
|
self._fallback = MockCarbonRepository()
|
|
72
79
|
self._warned_no_token = False
|
|
80
|
+
self._down_until: dict[str, float] = {} # EIC -> epoch secs; skip the zone until then
|
|
73
81
|
|
|
74
82
|
async def carbon_now(self, provider: Provider, region: str) -> CarbonReading:
|
|
75
83
|
meta = region_meta(provider, region) or {}
|
|
@@ -114,28 +122,57 @@ class ENTSOERepository:
|
|
|
114
122
|
"periodEnd": now.strftime("%Y%m%d%H%M"),
|
|
115
123
|
}
|
|
116
124
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
125
|
+
return await self._get_bounded(eic, params, label="A75")
|
|
126
|
+
|
|
127
|
+
def _is_down(self, eic: str) -> bool:
|
|
128
|
+
return time.time() < self._down_until.get(eic, 0.0)
|
|
129
|
+
|
|
130
|
+
async def _get_bounded(self, eic: str, params: dict, label: str,
|
|
131
|
+
no_match_is_final: bool = False) -> str | None:
|
|
132
|
+
"""GET with retries inside ONE wall-clock budget, and outage memory.
|
|
133
|
+
|
|
134
|
+
ENTSO-E is occasionally slow or empty for a zone (their docs warn about response times), so
|
|
135
|
+
we retry — but every attempt and backoff shares a single _FETCH_BUDGET_S, so a struggling
|
|
136
|
+
upstream degrades this call to the labelled fallback in seconds rather than freezing the tool.
|
|
137
|
+
If the zone still fails, remember it for _OUTAGE_COOLDOWN_S: the next calls (e.g. the other
|
|
138
|
+
regions in a ranking, then the timing lookup) fall back immediately instead of each waiting
|
|
139
|
+
out their own retry cycle. Returns the body text, or None (callers fall back)."""
|
|
140
|
+
if self._is_down(eic):
|
|
141
|
+
return None
|
|
142
|
+
deadline = time.monotonic() + _FETCH_BUDGET_S
|
|
143
|
+
for attempt in range(1, _ATTEMPTS + 1):
|
|
144
|
+
remaining = deadline - time.monotonic()
|
|
145
|
+
if remaining <= 0.5:
|
|
146
|
+
break
|
|
123
147
|
try:
|
|
124
|
-
async with httpx.AsyncClient(timeout=
|
|
125
|
-
resp = await client.get(_URL, params=params)
|
|
148
|
+
async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT) as client:
|
|
149
|
+
resp = await asyncio.wait_for(client.get(_URL, params=params),
|
|
150
|
+
timeout=min(remaining, REQUEST_DEADLINE_S))
|
|
126
151
|
if resp.status_code in (401, 403):
|
|
127
|
-
log.warning("ENTSO-E rejected the token (HTTP %s) — using
|
|
128
|
-
resp.status_code)
|
|
152
|
+
log.warning("ENTSO-E rejected the token (HTTP %s) on %s — using fallback. "
|
|
153
|
+
"Check ENTSOE_TOKEN.", resp.status_code, label)
|
|
129
154
|
return None # auth: don't retry
|
|
130
155
|
resp.raise_for_status()
|
|
131
156
|
if resp.text and "<TimeSeries" in resp.text:
|
|
132
157
|
return resp.text
|
|
133
|
-
#
|
|
134
|
-
|
|
158
|
+
# 'No matching data' (common for A65 in some zones) means "not offered", not an
|
|
159
|
+
# outage: bail quietly without retrying or marking the zone down.
|
|
160
|
+
if no_match_is_final and "No matching data" in resp.text:
|
|
161
|
+
log.debug("ENTSO-E %s: no matching data for %s", label, eic)
|
|
162
|
+
return None
|
|
163
|
+
log.debug("ENTSO-E %s empty for %s (attempt %d/%d)", label, eic, attempt, _ATTEMPTS)
|
|
135
164
|
except Exception as e:
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
165
|
+
# describe_error, not e: the exception text embeds the URL, which carries the token.
|
|
166
|
+
log.debug("ENTSO-E %s failed for %s (attempt %d/%d): %s",
|
|
167
|
+
label, eic, attempt, _ATTEMPTS, describe_error(e))
|
|
168
|
+
if attempt < _ATTEMPTS:
|
|
169
|
+
pause = 0.6 * attempt # brief backoff: 0.6s, 1.2s — inside the same budget
|
|
170
|
+
if deadline - time.monotonic() <= pause + 0.5:
|
|
171
|
+
break
|
|
172
|
+
await asyncio.sleep(pause)
|
|
173
|
+
self._down_until[eic] = time.time() + _OUTAGE_COOLDOWN_S
|
|
174
|
+
log.info("ENTSO-E unavailable for %s — using the labelled fallback for the next %ds.",
|
|
175
|
+
eic, int(_OUTAGE_COOLDOWN_S))
|
|
139
176
|
return None
|
|
140
177
|
|
|
141
178
|
async def _fetch_forecast_xml(self, eic: str, document_type: str, hours_ahead: int,
|
|
@@ -153,31 +190,7 @@ class ENTSOERepository:
|
|
|
153
190
|
"periodEnd": end.strftime("%Y%m%d%H%M"),
|
|
154
191
|
}
|
|
155
192
|
params["outBiddingZone_Domain" if load else "in_Domain"] = eic
|
|
156
|
-
|
|
157
|
-
import asyncio
|
|
158
|
-
attempts = 3
|
|
159
|
-
for attempt in range(1, attempts + 1):
|
|
160
|
-
try:
|
|
161
|
-
async with httpx.AsyncClient(timeout=15) as client:
|
|
162
|
-
resp = await client.get(_URL, params=params)
|
|
163
|
-
if resp.status_code in (401, 403):
|
|
164
|
-
log.warning("ENTSO-E rejected the token (HTTP %s) on forecast — using fallback.",
|
|
165
|
-
resp.status_code)
|
|
166
|
-
return None
|
|
167
|
-
resp.raise_for_status()
|
|
168
|
-
if resp.text and "<TimeSeries" in resp.text:
|
|
169
|
-
return resp.text
|
|
170
|
-
# 'No matching data' (common for A65 in some zones) is not retryable — bail quietly.
|
|
171
|
-
if "No matching data" in resp.text:
|
|
172
|
-
log.debug("ENTSO-E %s: no matching data for %s", document_type, eic)
|
|
173
|
-
return None
|
|
174
|
-
log.debug("ENTSO-E %s empty for %s (attempt %d/%d)", document_type, eic, attempt, attempts)
|
|
175
|
-
except Exception as e:
|
|
176
|
-
log.debug("ENTSO-E %s failed for %s (attempt %d/%d): %s",
|
|
177
|
-
document_type, eic, attempt, attempts, e)
|
|
178
|
-
if attempt < attempts:
|
|
179
|
-
await asyncio.sleep(0.6 * attempt)
|
|
180
|
-
return None
|
|
193
|
+
return await self._get_bounded(eic, params, label=document_type, no_match_is_final=True)
|
|
181
194
|
|
|
182
195
|
async def renewable_forecast(self, provider: Provider, region: str, hours_ahead: int = 24
|
|
183
196
|
) -> list[ForecastHour]:
|
|
@@ -18,7 +18,29 @@ from .services import (
|
|
|
18
18
|
)
|
|
19
19
|
from .models import ServingHub
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
# Sent to every MCP client at connect. Agents learn a server from its tool list, not its README, and
|
|
22
|
+
# the tool list starts with region-picking — so without this they lead with the rarest decision and
|
|
23
|
+
# skip the most valuable one. This names the strongest uses first and the reporting rules that matter.
|
|
24
|
+
INSTRUCTIONS = """\
|
|
25
|
+
GreenPick advises on cloud infrastructure decisions BEFORE you deploy, weighing carbon, cost and \
|
|
26
|
+
latency together. Strongest uses, in order:
|
|
27
|
+
1. Right-sizing (rightsizing_check): an over-provisioned instance cut to a standard, buyable size \
|
|
28
|
+
saves money and carbon in the same move. Lead with the monthly dollar saving.
|
|
29
|
+
2. Scheduling deferrable jobs: cleanest_hours_to_run (one-off), cleanest_time_of_day (recurring), \
|
|
30
|
+
carbon_forecast (EU day-ahead).
|
|
31
|
+
3. GPU training and LLM inference footprints: estimate_training_carbon (gpu_hours is the TOTAL across \
|
|
32
|
+
all GPUs; report wall_clock_hours as the run length), estimate_inference_carbon.
|
|
33
|
+
4. Efficiency and placement: greener_instance_family, compare_regions_tri_axis, greener_alternative_to, \
|
|
34
|
+
serving_pattern_advice; carbon_metric_scaffold for an app to track its own estimated carbon.
|
|
35
|
+
Before recommending a region move, run data_locality_egress_check: egress can erase the win. Mention \
|
|
36
|
+
any residency_note.
|
|
37
|
+
Every carbon figure carries source and quality fields. Report 'mock', 'cached' or 'historical' values \
|
|
38
|
+
as such, never as live readings. If a call fails or output is missing, say so rather than describing \
|
|
39
|
+
what the tool would return. GreenPick estimates; it does not measure running systems or report model \
|
|
40
|
+
accuracy. Use explain_methodology_and_limits when asked how a number was produced.\
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
mcp = FastMCP("greenpick", instructions=INSTRUCTIONS)
|
|
22
44
|
_repo = get_repository()
|
|
23
45
|
|
|
24
46
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: greenpick
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.6
|
|
4
4
|
Summary: Design-time sustainability co-pilot (MCP server): greener, cheaper cloud infrastructure decisions with carbon + cost + latency tradeoffs.
|
|
5
5
|
Author-email: "Data Science Advisers, LLC" <info@datascienceadvisers.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -254,15 +254,39 @@ of the options below.)
|
|
|
254
254
|
}
|
|
255
255
|
```
|
|
256
256
|
|
|
257
|
-
**In a Jupyter notebook or Python script**,
|
|
257
|
+
**In a Jupyter notebook or Python script**, connect with the MCP client library and pass the keys
|
|
258
|
+
in `env`. The library starts GreenPick as a separate process and doesn't forward the notebook's own
|
|
259
|
+
environment variables to it:
|
|
258
260
|
|
|
259
261
|
```python
|
|
260
|
-
import
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
262
|
+
import sys
|
|
263
|
+
from mcp import ClientSession, StdioServerParameters
|
|
264
|
+
from mcp.client.stdio import stdio_client
|
|
265
|
+
|
|
266
|
+
params = StdioServerParameters(
|
|
267
|
+
command=sys.executable,
|
|
268
|
+
args=["-m", "greenpick"],
|
|
269
|
+
env={
|
|
270
|
+
"GREENPICK_CARBON_SOURCE": "auto",
|
|
271
|
+
"EIA_API_KEY": "your-eia-key",
|
|
272
|
+
"ENTSOE_TOKEN": "your-entsoe-token",
|
|
273
|
+
},
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
# In Jupyter, errlog must be a real file (the notebook's stderr has no fileno()).
|
|
277
|
+
with open("greenpick-server.log", "w") as errlog:
|
|
278
|
+
async with stdio_client(params, errlog=errlog) as (read, write):
|
|
279
|
+
async with ClientSession(read, write) as session:
|
|
280
|
+
await session.initialize()
|
|
281
|
+
result = await session.call_tool(
|
|
282
|
+
"region_carbon_now", {"provider": "aws", "region": "us-west-2"}
|
|
283
|
+
)
|
|
284
|
+
print(result.content[0].text)
|
|
264
285
|
```
|
|
265
286
|
|
|
287
|
+
Top-level `await` works in Jupyter. In a plain script, put the `with` block inside an `async def`
|
|
288
|
+
and run it with `asyncio.run(...)`.
|
|
289
|
+
|
|
266
290
|
Every carbon figure includes a `quality` field: `live` means real grid data, `mock` means a
|
|
267
291
|
fallback value. Check it to confirm live data is on.
|
|
268
292
|
|
|
@@ -13,6 +13,7 @@ src/greenpick.egg-info/entry_points.txt
|
|
|
13
13
|
src/greenpick.egg-info/requires.txt
|
|
14
14
|
src/greenpick.egg-info/top_level.txt
|
|
15
15
|
src/greenpick/data/__init__.py
|
|
16
|
+
src/greenpick/data/_net.py
|
|
16
17
|
src/greenpick/data/auto_repository.py
|
|
17
18
|
src/greenpick/data/eia_repository.py
|
|
18
19
|
src/greenpick/data/electricitymaps_repository.py
|
|
@@ -1143,3 +1143,111 @@ async def test_serving_echoes_resolved_hub_coordinates():
|
|
|
1143
1143
|
def test_catalog_has_no_invented_aws_new_york_region():
|
|
1144
1144
|
from greenpick.data.static_data import regions
|
|
1145
1145
|
assert "us-east-nyc" not in regions()["aws"]
|
|
1146
|
+
|
|
1147
|
+
|
|
1148
|
+
# --- 0.1.5: secrets never reach logs; a slow upstream can't freeze a tool call ---
|
|
1149
|
+
|
|
1150
|
+
import logging
|
|
1151
|
+
|
|
1152
|
+
|
|
1153
|
+
def test_http_libraries_do_not_log_request_urls():
|
|
1154
|
+
import greenpick # noqa: F401 (import side effect sets the levels)
|
|
1155
|
+
# httpx logs full request URLs (which carry our API keys) at INFO; we keep it at WARNING.
|
|
1156
|
+
assert logging.getLogger("httpx").getEffectiveLevel() >= logging.WARNING
|
|
1157
|
+
assert logging.getLogger("httpcore").getEffectiveLevel() >= logging.WARNING
|
|
1158
|
+
|
|
1159
|
+
|
|
1160
|
+
@pytest.mark.asyncio
|
|
1161
|
+
async def test_eia_key_never_appears_in_logs(monkeypatch, caplog):
|
|
1162
|
+
caplog.set_level(logging.DEBUG)
|
|
1163
|
+
secret = "EIA-SECRET-123"
|
|
1164
|
+
|
|
1165
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
1166
|
+
return httpx.Response(500, text="boom") # raise_for_status -> exception text embeds URL
|
|
1167
|
+
|
|
1168
|
+
orig = httpx.AsyncClient
|
|
1169
|
+
monkeypatch.setattr(httpx, "AsyncClient",
|
|
1170
|
+
lambda *a, **k: orig(transport=_mock_transport(handler)))
|
|
1171
|
+
r = await EIARepository(api_key=secret).carbon_now("aws", "us-east-1")
|
|
1172
|
+
assert r.quality == "mock"
|
|
1173
|
+
assert secret not in caplog.text
|
|
1174
|
+
|
|
1175
|
+
|
|
1176
|
+
@pytest.mark.asyncio
|
|
1177
|
+
async def test_entsoe_token_never_appears_in_logs(monkeypatch, caplog):
|
|
1178
|
+
caplog.set_level(logging.DEBUG)
|
|
1179
|
+
secret = "ENTSOE-SECRET-456"
|
|
1180
|
+
|
|
1181
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
1182
|
+
return httpx.Response(503, text="unavailable")
|
|
1183
|
+
|
|
1184
|
+
orig = httpx.AsyncClient
|
|
1185
|
+
monkeypatch.setattr(httpx, "AsyncClient",
|
|
1186
|
+
lambda *a, **k: orig(transport=_mock_transport(handler)))
|
|
1187
|
+
monkeypatch.setattr("greenpick.data.entsoe_repository.asyncio.sleep", _no_sleep)
|
|
1188
|
+
r = await ENTSOERepository(token=secret).carbon_now("aws", "eu-north-1")
|
|
1189
|
+
assert r.quality == "mock"
|
|
1190
|
+
assert secret not in caplog.text
|
|
1191
|
+
|
|
1192
|
+
|
|
1193
|
+
async def _no_sleep(_seconds):
|
|
1194
|
+
return None
|
|
1195
|
+
|
|
1196
|
+
|
|
1197
|
+
@pytest.mark.asyncio
|
|
1198
|
+
async def test_entsoe_slow_upstream_is_bounded(monkeypatch):
|
|
1199
|
+
import asyncio, time as _time
|
|
1200
|
+
from greenpick.data import entsoe_repository as er
|
|
1201
|
+
|
|
1202
|
+
async def handler(request: httpx.Request) -> httpx.Response:
|
|
1203
|
+
await asyncio.sleep(30) # an upstream that never answers in time
|
|
1204
|
+
return httpx.Response(200, text="")
|
|
1205
|
+
|
|
1206
|
+
orig = httpx.AsyncClient
|
|
1207
|
+
monkeypatch.setattr(httpx, "AsyncClient",
|
|
1208
|
+
lambda *a, **k: orig(transport=_mock_transport(handler)))
|
|
1209
|
+
monkeypatch.setattr(er, "_FETCH_BUDGET_S", 1.0)
|
|
1210
|
+
start = _time.monotonic()
|
|
1211
|
+
r = await ENTSOERepository(token="t").carbon_now("aws", "eu-north-1")
|
|
1212
|
+
assert r.quality == "mock" # degraded to the labelled fallback...
|
|
1213
|
+
assert _time.monotonic() - start < 3.0 # ...within the budget, not 30s+
|
|
1214
|
+
|
|
1215
|
+
|
|
1216
|
+
@pytest.mark.asyncio
|
|
1217
|
+
async def test_entsoe_remembers_an_outage(monkeypatch):
|
|
1218
|
+
calls = {"n": 0}
|
|
1219
|
+
|
|
1220
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
1221
|
+
calls["n"] += 1
|
|
1222
|
+
raise httpx.ConnectError("down", request=request)
|
|
1223
|
+
|
|
1224
|
+
orig = httpx.AsyncClient
|
|
1225
|
+
monkeypatch.setattr(httpx, "AsyncClient",
|
|
1226
|
+
lambda *a, **k: orig(transport=_mock_transport(handler)))
|
|
1227
|
+
monkeypatch.setattr("greenpick.data.entsoe_repository.asyncio.sleep", _no_sleep)
|
|
1228
|
+
repo = ENTSOERepository(token="t")
|
|
1229
|
+
await repo.carbon_now("aws", "eu-north-1")
|
|
1230
|
+
after_first = calls["n"]
|
|
1231
|
+
assert after_first >= 1
|
|
1232
|
+
# Same zone again (now, and as history): skipped immediately, no new upstream calls.
|
|
1233
|
+
again = await repo.carbon_now("aws", "eu-north-1")
|
|
1234
|
+
await repo.carbon_history("aws", "eu-north-1")
|
|
1235
|
+
assert again.quality == "mock"
|
|
1236
|
+
assert calls["n"] == after_first
|
|
1237
|
+
|
|
1238
|
+
|
|
1239
|
+
# --- 0.1.6: server-level instructions steer agents (they read the tool list, not the README) ---
|
|
1240
|
+
|
|
1241
|
+
def test_server_sends_instructions_that_lead_with_rightsizing():
|
|
1242
|
+
from greenpick import server
|
|
1243
|
+
text = server.mcp.instructions or ""
|
|
1244
|
+
assert "rightsizing_check" in text
|
|
1245
|
+
# Right-sizing is named before region-picking (the tool list itself starts with region tools).
|
|
1246
|
+
assert text.index("rightsizing_check") < text.index("compare_regions_tri_axis")
|
|
1247
|
+
# The reporting rules that outside agents got wrong in testing.
|
|
1248
|
+
assert "mock" in text and "TOTAL" in text and "data_locality_egress_check" in text
|
|
1249
|
+
# Every tool named in the instructions actually exists.
|
|
1250
|
+
import re, asyncio
|
|
1251
|
+
named = set(re.findall(r"\b[a-z]+(?:_[a-z]+)+\b", text)) - {"wall_clock_hours", "gpu_hours", "residency_note"}
|
|
1252
|
+
registered = {t.name for t in asyncio.run(server.mcp.list_tools())}
|
|
1253
|
+
assert named <= registered, named - registered
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|