greenpick 0.6.1__tar.gz → 0.6.3__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.
Files changed (59) hide show
  1. {greenpick-0.6.1/src/greenpick.egg-info → greenpick-0.6.3}/PKG-INFO +7 -1
  2. {greenpick-0.6.1 → greenpick-0.6.3}/README.md +6 -0
  3. {greenpick-0.6.1 → greenpick-0.6.3}/pyproject.toml +1 -1
  4. {greenpick-0.6.1 → greenpick-0.6.3}/src/greenpick/__init__.py +1 -1
  5. {greenpick-0.6.1 → greenpick-0.6.3}/src/greenpick/server.py +27 -4
  6. {greenpick-0.6.1 → greenpick-0.6.3}/src/greenpick/services/data_hint.py +4 -2
  7. {greenpick-0.6.1 → greenpick-0.6.3}/src/greenpick/services/justification_service.py +5 -1
  8. greenpick-0.6.3/src/greenpick/services/sources.py +101 -0
  9. {greenpick-0.6.1 → greenpick-0.6.3/src/greenpick.egg-info}/PKG-INFO +7 -1
  10. {greenpick-0.6.1 → greenpick-0.6.3}/src/greenpick.egg-info/SOURCES.txt +1 -0
  11. {greenpick-0.6.1 → greenpick-0.6.3}/tests/test_services.py +102 -1
  12. {greenpick-0.6.1 → greenpick-0.6.3}/LICENSE +0 -0
  13. {greenpick-0.6.1 → greenpick-0.6.3}/setup.cfg +0 -0
  14. {greenpick-0.6.1 → greenpick-0.6.3}/src/greenpick/__main__.py +0 -0
  15. {greenpick-0.6.1 → greenpick-0.6.3}/src/greenpick/config.py +0 -0
  16. {greenpick-0.6.1 → greenpick-0.6.3}/src/greenpick/data/__init__.py +0 -0
  17. {greenpick-0.6.1 → greenpick-0.6.3}/src/greenpick/data/_net.py +0 -0
  18. {greenpick-0.6.1 → greenpick-0.6.3}/src/greenpick/data/annual.json +0 -0
  19. {greenpick-0.6.1 → greenpick-0.6.3}/src/greenpick/data/auto_repository.py +0 -0
  20. {greenpick-0.6.1 → greenpick-0.6.3}/src/greenpick/data/canada_repository.py +0 -0
  21. {greenpick-0.6.1 → greenpick-0.6.3}/src/greenpick/data/eia_repository.py +0 -0
  22. {greenpick-0.6.1 → greenpick-0.6.3}/src/greenpick/data/electricitymaps_repository.py +0 -0
  23. {greenpick-0.6.1 → greenpick-0.6.3}/src/greenpick/data/emission_factors.json +0 -0
  24. {greenpick-0.6.1 → greenpick-0.6.3}/src/greenpick/data/entsoe_repository.py +0 -0
  25. {greenpick-0.6.1 → greenpick-0.6.3}/src/greenpick/data/gpu_power.json +0 -0
  26. {greenpick-0.6.1 → greenpick-0.6.3}/src/greenpick/data/inference_energy.json +0 -0
  27. {greenpick-0.6.1 → greenpick-0.6.3}/src/greenpick/data/instance_families.json +0 -0
  28. {greenpick-0.6.1 → greenpick-0.6.3}/src/greenpick/data/mock_repository.py +0 -0
  29. {greenpick-0.6.1 → greenpick-0.6.3}/src/greenpick/data/network.json +0 -0
  30. {greenpick-0.6.1 → greenpick-0.6.3}/src/greenpick/data/power.json +0 -0
  31. {greenpick-0.6.1 → greenpick-0.6.3}/src/greenpick/data/pricing.json +0 -0
  32. {greenpick-0.6.1 → greenpick-0.6.3}/src/greenpick/data/regions.json +0 -0
  33. {greenpick-0.6.1 → greenpick-0.6.3}/src/greenpick/data/repository.py +0 -0
  34. {greenpick-0.6.1 → greenpick-0.6.3}/src/greenpick/data/static_data.py +0 -0
  35. {greenpick-0.6.1 → greenpick-0.6.3}/src/greenpick/data/typical.json +0 -0
  36. {greenpick-0.6.1 → greenpick-0.6.3}/src/greenpick/data/typical_repository.py +0 -0
  37. {greenpick-0.6.1 → greenpick-0.6.3}/src/greenpick/models.py +0 -0
  38. {greenpick-0.6.1 → greenpick-0.6.3}/src/greenpick/services/__init__.py +0 -0
  39. {greenpick-0.6.1 → greenpick-0.6.3}/src/greenpick/services/carbon_service.py +0 -0
  40. {greenpick-0.6.1 → greenpick-0.6.3}/src/greenpick/services/cost_service.py +0 -0
  41. {greenpick-0.6.1 → greenpick-0.6.3}/src/greenpick/services/forecast_service.py +0 -0
  42. {greenpick-0.6.1 → greenpick-0.6.3}/src/greenpick/services/inference_service.py +0 -0
  43. {greenpick-0.6.1 → greenpick-0.6.3}/src/greenpick/services/instance_service.py +0 -0
  44. {greenpick-0.6.1 → greenpick-0.6.3}/src/greenpick/services/latency_service.py +0 -0
  45. {greenpick-0.6.1 → greenpick-0.6.3}/src/greenpick/services/locality_service.py +0 -0
  46. {greenpick-0.6.1 → greenpick-0.6.3}/src/greenpick/services/metric_scaffold_service.py +0 -0
  47. {greenpick-0.6.1 → greenpick-0.6.3}/src/greenpick/services/migration_service.py +0 -0
  48. {greenpick-0.6.1 → greenpick-0.6.3}/src/greenpick/services/offhours_service.py +0 -0
  49. {greenpick-0.6.1 → greenpick-0.6.3}/src/greenpick/services/recommender.py +0 -0
  50. {greenpick-0.6.1 → greenpick-0.6.3}/src/greenpick/services/residency_service.py +0 -0
  51. {greenpick-0.6.1 → greenpick-0.6.3}/src/greenpick/services/rightsizing_service.py +0 -0
  52. {greenpick-0.6.1 → greenpick-0.6.3}/src/greenpick/services/scheduling_service.py +0 -0
  53. {greenpick-0.6.1 → greenpick-0.6.3}/src/greenpick/services/serving_service.py +0 -0
  54. {greenpick-0.6.1 → greenpick-0.6.3}/src/greenpick/services/training_service.py +0 -0
  55. {greenpick-0.6.1 → greenpick-0.6.3}/src/greenpick/services/typical_ranking_service.py +0 -0
  56. {greenpick-0.6.1 → greenpick-0.6.3}/src/greenpick.egg-info/dependency_links.txt +0 -0
  57. {greenpick-0.6.1 → greenpick-0.6.3}/src/greenpick.egg-info/entry_points.txt +0 -0
  58. {greenpick-0.6.1 → greenpick-0.6.3}/src/greenpick.egg-info/requires.txt +0 -0
  59. {greenpick-0.6.1 → greenpick-0.6.3}/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.6.1
3
+ Version: 0.6.3
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
@@ -226,6 +226,8 @@ flow-traced consumption. Cost is a ballpark, not a quote. Latency is a geographi
226
226
 
227
227
  ## Quick start
228
228
 
229
+ <a href="https://mcprush.com/bithug01/greenpick-mcp"><img src="https://mcprush.com/badge/greenpick-mcp/card.svg" width="220" alt="GreenPick MCP server on mcprush" /></a>
230
+
229
231
  ```bash
230
232
  # Python 3.11+
231
233
  pip install greenpick # from PyPI
@@ -308,6 +310,10 @@ with open("greenpick-server.log", "w") as errlog:
308
310
  Top-level `await` works in Jupyter. In a plain script, put the `with` block inside an `async def`
309
311
  and run it with `asyncio.run(...)`.
310
312
 
313
+ Using an AI assistant inside a notebook? Paste the
314
+ [starter prompt](https://github.com/bithug01/greenpick/blob/master/docs/using-from-a-notebook.md)
315
+ as your first message: it sets up the connection and points the assistant at the right tool for each question.
316
+
311
317
  Every carbon figure includes a `quality` field: `live` means a current reading, `typical` means
312
318
  this month's average from last year's real data, `annual_average` means a sourced yearly national
313
319
  figure (regions with no free hourly data), and `mock` means a hand-set value. Check it to confirm
@@ -196,6 +196,8 @@ flow-traced consumption. Cost is a ballpark, not a quote. Latency is a geographi
196
196
 
197
197
  ## Quick start
198
198
 
199
+ <a href="https://mcprush.com/bithug01/greenpick-mcp"><img src="https://mcprush.com/badge/greenpick-mcp/card.svg" width="220" alt="GreenPick MCP server on mcprush" /></a>
200
+
199
201
  ```bash
200
202
  # Python 3.11+
201
203
  pip install greenpick # from PyPI
@@ -278,6 +280,10 @@ with open("greenpick-server.log", "w") as errlog:
278
280
  Top-level `await` works in Jupyter. In a plain script, put the `with` block inside an `async def`
279
281
  and run it with `asyncio.run(...)`.
280
282
 
283
+ Using an AI assistant inside a notebook? Paste the
284
+ [starter prompt](https://github.com/bithug01/greenpick/blob/master/docs/using-from-a-notebook.md)
285
+ as your first message: it sets up the connection and points the assistant at the right tool for each question.
286
+
281
287
  Every carbon figure includes a `quality` field: `live` means a current reading, `typical` means
282
288
  this month's average from last year's real data, `annual_average` means a sourced yearly national
283
289
  figure (regions with no free hourly data), and `mock` means a hand-set value. Check it to confirm
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "greenpick"
3
- version = "0.6.1"
3
+ version = "0.6.3"
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"
@@ -4,7 +4,7 @@ Greener cloud-region choices with honest carbon + cost + latency tradeoffs,
4
4
  delivered inside the vibe-coding loop. Directional guidance, not audited accounting.
5
5
  """
6
6
 
7
- __version__ = "0.6.1"
7
+ __version__ = "0.6.3"
8
8
 
9
9
  # httpx logs every request URL at INFO, and our live sources pass their API key / token as a URL
10
10
  # query parameter — so at the default log level the secret would land in the MCP client's logs.
@@ -30,6 +30,7 @@ from .services import (
30
30
  )
31
31
  from .models import ServingHub
32
32
  from .services.data_hint import with_hint
33
+ from .services.sources import with_sources
33
34
  from .data.typical_repository import typical_context
34
35
 
35
36
  # Sent to every MCP client at connect. Agents learn a server from its tool list, not its README, and
@@ -54,12 +55,25 @@ for a pull request, use region_move_justification.
54
55
  Every carbon figure carries source and quality fields. Report 'typical', 'mock', 'cached' or \
55
56
  'historical' values as such, never as live readings — including figures derived from them, such as a kg CO2 saving. \
56
57
  When a result includes live_data_hint, pass it on: it says why the data isn't live and how to turn \
57
- live data on. If a call fails or output is missing, say so rather than describing \
58
+ live data on. Results with carbon or cost figures include verify_at: the public page behind each \
59
+ figure, for anyone who wants to check it. If a call fails or output is missing, say so rather than describing \
58
60
  what the tool would return. GreenPick estimates; it does not measure running systems or report model \
59
61
  accuracy. Use explain_methodology_and_limits when asked how a number was produced.\
60
62
  """
61
63
 
62
64
  mcp = FastMCP("greenpick", instructions=INSTRUCTIONS)
65
+
66
+
67
+ def _fraction_error(name: str, value: Optional[float], allow_zero: bool = False) -> Optional[str]:
68
+ """Utilisation inputs are fractions (0.15 = 15%). A value like 15 is almost always a percentage;
69
+ say so rather than silently reading it as 1,500%."""
70
+ if value is None:
71
+ return None
72
+ low_ok = value >= 0 if allow_zero else value > 0
73
+ if low_ok and value <= 1:
74
+ return None
75
+ hint = f" Did you mean {value / 100:g}?" if 1 < value <= 100 else ""
76
+ return (f"{name} must be a fraction between 0 and 1 (0.15 means 15%); got {value:g}.{hint}")
63
77
  _repo = get_repository()
64
78
 
65
79
 
@@ -247,6 +261,11 @@ async def rightsizing_check(
247
261
  Returns the right-sized target (keeps ~40% headroom) with the monthly $ AND kg CO2 saving. Lead
248
262
  with the cost number, carbon is the twin. Directional, design-time — uses the utilisation you
249
263
  report, not a reading of your fleet."""
264
+ for name, value, zero in (("cpu_utilization", cpu_utilization, False),
265
+ ("ram_utilization", ram_utilization, True)):
266
+ err = _fraction_error(name, value, allow_zero=zero)
267
+ if err:
268
+ return {"provider": provider, "region": region, "error": err}
250
269
  advice = await rightsizing_service.rightsizing_check(
251
270
  _repo, provider, region, vcpu, ram_gb, cpu_utilization, # type: ignore[arg-type]
252
271
  ram_utilization=ram_utilization, hours_per_month=hours_per_month,
@@ -291,9 +310,9 @@ def greenest_regions_on_average(
291
310
  norm rather than a single hour; use region_carbon_now for what the grid is doing today. Carbon only
292
311
  (no cost or latency; compare_regions_tri_axis weighs all three). Offline, needs no keys. Regions
293
312
  labelled annual_average have only a yearly national figure and are not month-specific."""
294
- return typical_ranking_service.rank_regions(
313
+ return with_sources(typical_ranking_service.rank_regions(
295
314
  month=month, whole_year=whole_year, providers=providers, regions=regions, top_n=top_n,
296
- )
315
+ ))
297
316
 
298
317
 
299
318
  @mcp.tool(annotations=LIVE_DATA_TOOL)
@@ -470,7 +489,11 @@ async def estimate_training_carbon(
470
489
  portable and deferrable, so this combines region choice with cleanest-hours timing. Directional
471
490
  (TDP-based energy); GPU availability per region is NOT checked - verify your card is offered there.
472
491
  Training data is often large: if your dataset lives in another region, run data_locality_egress_check
473
- before shipping the job to the greenest region - the data egress can outweigh the carbon saving."""
492
+ before shipping the job to the greenest region - the data egress can outweigh the carbon saving.
493
+ `utilization` is an optional fraction of TDP (0..1, e.g. 0.6 for 60%)."""
494
+ err = _fraction_error("utilization", utilization)
495
+ if err:
496
+ return {"gpu": gpu, "error": err}
474
497
  advice = await training_service.estimate_training_carbon(
475
498
  _repo, gpu, gpu_count, gpu_hours, # type: ignore[arg-type]
476
499
  region=region, provider=provider, utilization=utilization, # type: ignore[arg-type]
@@ -180,8 +180,10 @@ def build_hint(result: dict) -> Optional[str]:
180
180
 
181
181
 
182
182
  def with_hint(result: dict) -> dict:
183
- """Add `live_data_hint` to a tool result that uses fallback data; leave others unchanged."""
183
+ """Add `live_data_hint` to a tool result that uses fallback data, and `verify_at` (where to check
184
+ its carbon and cost figures) to any result that has them. Existing fields are left unchanged."""
185
+ from .sources import with_sources
184
186
  hint = build_hint(result)
185
187
  if hint:
186
188
  result["live_data_hint"] = hint
187
- return result
189
+ return with_sources(result)
@@ -122,7 +122,11 @@ async def region_move_justification(
122
122
  if net_kg is not None:
123
123
  cost_part = (f" and about ${egress_low:,.0f}-${egress_high:,.0f} a month in egress"
124
124
  if egress_low is not None else "")
125
- if net_kg > 0:
125
+ if kg_saving <= 0:
126
+ parts.append(
127
+ f"The data stays in {from_provider}/{from_region} (~{monthly_cross_boundary_gb:,.0f} GB a "
128
+ f"month crossing), which adds about {transfer_kg:.1f} kg CO2{cost_part} on top.")
129
+ elif net_kg > 0:
126
130
  parts.append(
127
131
  f"The data stays in {from_provider}/{from_region} (~{monthly_cross_boundary_gb:,.0f} GB a "
128
132
  f"month crossing), which adds about {transfer_kg:.1f} kg CO2{cost_part}, "
@@ -0,0 +1,101 @@
1
+ """Where to check a number: the primary source behind each figure GreenPick reports.
2
+
3
+ GreenPick's estimates are directional, and its results say so. "Go and verify" is only useful if
4
+ it says WHERE, so every result that contains a carbon figure or a dollar figure also gets a short
5
+ `verify_at` list: which kind of figure, what it is based on, and the public page to check it against.
6
+
7
+ Kept deliberately small and stable: top-level pages that publish the data (grid operators, the
8
+ cloud providers' own pricing pages), not deep links that move. Additive only: it never changes or
9
+ removes an existing field.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ from typing import Any, Iterator
14
+
15
+ # Carbon: the base source name (after stripping "typical_") -> (what it is, where to check it).
16
+ _CARBON = {
17
+ "eia": ("US grid data from EIA-930 (hourly generation by fuel)",
18
+ "https://www.eia.gov/electricity/gridmonitor/"),
19
+ "entsoe": ("EU grid data from the ENTSO-E Transparency Platform (generation by fuel)",
20
+ "https://transparency.entsoe.eu/"),
21
+ "hq": ("Québec grid data from Hydro-Québec open data", "https://donnees.hydroquebec.com/"),
22
+ "ieso": ("Ontario grid data from IESO", "https://www.ieso.ca/en/Power-Data"),
23
+ "ember_annual": ("Yearly national grid intensity from Ember, via Our World in Data",
24
+ "https://ourworldindata.org/grapher/carbon-intensity-electricity"),
25
+ "electricitymaps": ("Grid data from Electricity Maps", "https://www.electricitymaps.com/"),
26
+ }
27
+
28
+ # Cost: provider -> (compute price page, data-transfer price page).
29
+ _PRICING = {
30
+ "aws": ("https://aws.amazon.com/ec2/pricing/on-demand/",
31
+ "https://aws.amazon.com/ec2/pricing/on-demand/"),
32
+ "gcp": ("https://cloud.google.com/compute/vm-instance-pricing",
33
+ "https://cloud.google.com/vpc/network-pricing"),
34
+ "azure": ("https://azure.microsoft.com/en-us/pricing/details/virtual-machines/linux/",
35
+ "https://azure.microsoft.com/en-us/pricing/details/bandwidth/"),
36
+ }
37
+ _PROVIDER_NAME = {"aws": "AWS", "gcp": "Google Cloud", "azure": "Azure"}
38
+
39
+ _SOURCE_KEYS = ("source", "carbon_source", "transfer_carbon_source",
40
+ "from_carbon_source", "to_carbon_source")
41
+ _PROVIDER_KEYS = ("provider", "compute_provider", "data_provider", "from_provider", "to_provider")
42
+ _LOCATION_KEYS = ("recommended_region", "compute_location", "data_location", "from", "to")
43
+
44
+
45
+ def _walk(obj: Any) -> Iterator[tuple[str, Any]]:
46
+ if isinstance(obj, dict):
47
+ for k, v in obj.items():
48
+ yield k, v
49
+ yield from _walk(v)
50
+ elif isinstance(obj, list):
51
+ for v in obj:
52
+ yield from _walk(v)
53
+
54
+
55
+ def verify_at(result: dict) -> list[dict]:
56
+ """The primary sources behind the carbon and cost figures in a tool result (may be empty)."""
57
+ carbon: set[str] = set()
58
+ providers: set[str] = set()
59
+ has_cost = has_egress = False
60
+ for key, value in _walk(result):
61
+ if key in _SOURCE_KEYS and isinstance(value, str):
62
+ base = value[len("typical_"):] if value.startswith("typical_") else value
63
+ if base in _CARBON:
64
+ carbon.add(base)
65
+ elif key in _PROVIDER_KEYS and value in _PRICING:
66
+ providers.add(value)
67
+ elif key in _LOCATION_KEYS and isinstance(value, str) and "/" in value:
68
+ prov = value.split("/", 1)[0]
69
+ if prov in _PRICING:
70
+ providers.add(prov)
71
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
72
+ if "usd" not in key:
73
+ continue
74
+ if "egress" in key:
75
+ has_egress = True
76
+ elif "cost" in key or "saving" in key: # compute cost or a saving on it
77
+ has_cost = True
78
+
79
+ out: list[dict] = []
80
+ for base in sorted(carbon):
81
+ what, url = _CARBON[base]
82
+ out.append({"figure": "grid carbon intensity", "based_on": what, "url": url})
83
+ for prov in sorted(providers):
84
+ compute_url, transfer_url = _PRICING[prov]
85
+ name = _PROVIDER_NAME[prov]
86
+ if has_cost:
87
+ out.append({"figure": "compute cost", "based_on": f"{name} on-demand list prices (dated)",
88
+ "url": compute_url})
89
+ if has_egress:
90
+ out.append({"figure": "data-transfer (egress) cost",
91
+ "based_on": f"{name} data-transfer prices (dated)", "url": transfer_url})
92
+ return out
93
+
94
+
95
+ def with_sources(result: dict) -> dict:
96
+ """Add `verify_at` to a result that contains carbon or cost figures; leave others unchanged."""
97
+ if isinstance(result, dict) and "error" not in result:
98
+ links = verify_at(result)
99
+ if links:
100
+ result["verify_at"] = links
101
+ return result
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: greenpick
3
- Version: 0.6.1
3
+ Version: 0.6.3
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
@@ -226,6 +226,8 @@ flow-traced consumption. Cost is a ballpark, not a quote. Latency is a geographi
226
226
 
227
227
  ## Quick start
228
228
 
229
+ <a href="https://mcprush.com/bithug01/greenpick-mcp"><img src="https://mcprush.com/badge/greenpick-mcp/card.svg" width="220" alt="GreenPick MCP server on mcprush" /></a>
230
+
229
231
  ```bash
230
232
  # Python 3.11+
231
233
  pip install greenpick # from PyPI
@@ -308,6 +310,10 @@ with open("greenpick-server.log", "w") as errlog:
308
310
  Top-level `await` works in Jupyter. In a plain script, put the `with` block inside an `async def`
309
311
  and run it with `asyncio.run(...)`.
310
312
 
313
+ Using an AI assistant inside a notebook? Paste the
314
+ [starter prompt](https://github.com/bithug01/greenpick/blob/master/docs/using-from-a-notebook.md)
315
+ as your first message: it sets up the connection and points the assistant at the right tool for each question.
316
+
311
317
  Every carbon figure includes a `quality` field: `live` means a current reading, `typical` means
312
318
  this month's average from last year's real data, `annual_average` means a sourced yearly national
313
319
  figure (regions with no free hourly data), and `mock` means a hand-set value. Check it to confirm
@@ -51,6 +51,7 @@ src/greenpick/services/residency_service.py
51
51
  src/greenpick/services/rightsizing_service.py
52
52
  src/greenpick/services/scheduling_service.py
53
53
  src/greenpick/services/serving_service.py
54
+ src/greenpick/services/sources.py
54
55
  src/greenpick/services/training_service.py
55
56
  src/greenpick/services/typical_ranking_service.py
56
57
  tests/test_services.py
@@ -1284,7 +1284,7 @@ def test_server_sends_instructions_that_lead_with_rightsizing():
1284
1284
  assert "mock" in text and "TOTAL" in text and "data_locality_egress_check" in text
1285
1285
  # Every tool named in the instructions actually exists.
1286
1286
  import re, asyncio
1287
- named = set(re.findall(r"\b[a-z]+(?:_[a-z]+)+\b", text)) - {"wall_clock_hours", "gpu_hours", "residency_note", "live_data_hint"}
1287
+ named = set(re.findall(r"\b[a-z]+(?:_[a-z]+)+\b", text)) - {"wall_clock_hours", "gpu_hours", "residency_note", "live_data_hint", "verify_at"}
1288
1288
  registered = {t.name for t in asyncio.run(server.mcp.list_tools())}
1289
1289
  assert named <= registered, named - registered
1290
1290
 
@@ -1990,3 +1990,104 @@ def test_move_tools_point_to_the_justification_tool():
1990
1990
  assert "region_move_justification" in desc["compare_regions_tri_axis"]
1991
1991
  assert "region_move_justification" in desc["greener_alternative_to"]
1992
1992
  assert "FULL dollar range" in desc["migration_payoff"]
1993
+
1994
+
1995
+ # --- docs/using-from-a-notebook.md stays in step with the real tool list ---
1996
+ def test_notebook_prompt_lists_every_tool_and_no_unknown_ones():
1997
+ import asyncio, re
1998
+ from pathlib import Path
1999
+ from greenpick import server
2000
+ names = {t.name for t in asyncio.run(server.mcp.list_tools())}
2001
+ text = (Path(__file__).resolve().parent.parent / "docs" / "using-from-a-notebook.md").read_text(encoding="utf-8")
2002
+ missing = sorted(n for n in names if n not in text)
2003
+ assert not missing, f"add these tools to docs/using-from-a-notebook.md: {missing}"
2004
+ routed = set()
2005
+ for line in text.splitlines():
2006
+ if line.startswith("- ") and " -> " in line:
2007
+ routed |= set(re.findall(r"[a-z][a-z0-9]*(?:_[a-z0-9]+)+", line.split(" -> ", 1)[1]))
2008
+ assert routed and routed <= names, f"unknown tool names in the routing list: {sorted(routed - names)}"
2009
+
2010
+
2011
+ # --- 0.6.2: a percentage passed as a utilisation fraction is rejected, not read as 1,500% ---
2012
+ @pytest.mark.asyncio
2013
+ @pytest.mark.parametrize("bad", [15, 1.5, 0, -0.1, 150])
2014
+ async def test_rightsizing_rejects_non_fraction_cpu_utilization(bad):
2015
+ r = await _tool("rightsizing_check")(provider="aws", region="us-east-1", vcpu=8, ram_gb=32, cpu_utilization=bad)
2016
+ assert "error" in r and "fraction" in r["error"]
2017
+ if bad == 15:
2018
+ assert "Did you mean 0.15?" in r["error"]
2019
+
2020
+
2021
+ @pytest.mark.asyncio
2022
+ async def test_rightsizing_fraction_still_works_and_ram_is_checked(monkeypatch):
2023
+ from greenpick import server
2024
+ monkeypatch.setattr(server, "_repo", MockCarbonRepository())
2025
+ ok = await _tool("rightsizing_check")(provider="aws", region="us-east-1", vcpu=8, ram_gb=32, cpu_utilization=0.15)
2026
+ assert "error" not in ok and ok["recommended_vcpu"] == 4
2027
+ bad = await _tool("rightsizing_check")(provider="aws", region="us-east-1", vcpu=8, ram_gb=32,
2028
+ cpu_utilization=0.15, ram_utilization=20)
2029
+ assert "ram_utilization" in bad["error"] and "0.2" in bad["error"]
2030
+
2031
+
2032
+ @pytest.mark.asyncio
2033
+ async def test_training_rejects_percentage_utilization():
2034
+ r = await _tool("estimate_training_carbon")(gpu="a100", gpu_count=8, gpu_hours=40, utilization=60)
2035
+ assert "error" in r and "Did you mean 0.6?" in r["error"]
2036
+
2037
+
2038
+ # --- 0.6.3: verify_at says where to check each carbon and cost figure (additive only) ---
2039
+ def test_verify_at_maps_sources_and_costs():
2040
+ from greenpick.services.sources import verify_at
2041
+ r = {"carbon_source": "typical_eia", "provider": "aws", "cost_now_usd_per_month": 10.0}
2042
+ figs = {(v["figure"], v["url"]) for v in verify_at(r)}
2043
+ assert ("grid carbon intensity", "https://www.eia.gov/electricity/gridmonitor/") in figs
2044
+ assert ("compute cost", "https://aws.amazon.com/ec2/pricing/on-demand/") in figs
2045
+ eg = verify_at({"data_location": "gcp/us-east4", "est_monthly_egress_usd_low": 1.0, "transfer_carbon_source": "ember_annual"})
2046
+ assert {v["figure"] for v in eg} == {"data-transfer (egress) cost", "grid carbon intensity"}
2047
+ assert verify_at({"provider": "aws", "net_over_horizon_usd_low": 3.0}) == [] # not a compute cost
2048
+ assert verify_at({"note": "nothing to check"}) == []
2049
+
2050
+
2051
+ @pytest.mark.asyncio
2052
+ async def test_tools_add_verify_at_but_not_on_errors(monkeypatch):
2053
+ from greenpick import server
2054
+ monkeypatch.setattr(server, "_repo", TypicalCarbonRepository())
2055
+ r = await _tool("rightsizing_check")(provider="aws", region="us-east-1", vcpu=8, ram_gb=32, cpu_utilization=0.15)
2056
+ assert {v["figure"] for v in r["verify_at"]} == {"grid carbon intensity", "compute cost"}
2057
+ rank = _tool("greenest_regions_on_average")(month=7, regions=["us-west-2", "ap-northeast-1"])
2058
+ assert any("ourworldindata" in v["url"] for v in rank["verify_at"])
2059
+ bad = await _tool("rightsizing_check")(provider="aws", region="us-east-1", vcpu=8, ram_gb=32, cpu_utilization=15)
2060
+ assert "error" in bad and "verify_at" not in bad
2061
+
2062
+
2063
+ # --- The notebook starter prompt (docs/using-from-a-notebook.md) relies on these fields. ---
2064
+ # Testers and workshop attendees run that prompt against whatever version pip installs, so a
2065
+ # release must never rename or drop them.
2066
+ @pytest.mark.asyncio
2067
+ async def test_fields_the_notebook_prompt_relies_on(monkeypatch):
2068
+ from greenpick import server
2069
+ monkeypatch.setattr(server, "_repo", TypicalCarbonRepository())
2070
+ now = await _tool("region_carbon_now")(provider="aws", region="us-east-1")
2071
+ assert {"source", "quality", "live_data_hint"} <= set(now)
2072
+ eg = await _tool("data_locality_egress_check")(compute_provider="aws", compute_region="eu-north-1",
2073
+ data_provider="aws", data_region="us-east-1",
2074
+ monthly_cross_boundary_gb=100)
2075
+ assert next(iter(eg)) == "summary" and eg["summary"].startswith("NO NET VERDICT")
2076
+ assert {"residency_note", "limitations"} <= set(eg)
2077
+ mp = await _tool("migration_payoff")("aws", "us-east-1", "aws", "eu-north-1", 2000, 300)
2078
+ assert next(iter(mp)) == "summary" and "net_over_horizon_usd_low" in mp and "net_over_horizon_usd_high" in mp
2079
+ pr = await _tool("region_move_justification")("aws", "us-east-1", "aws", "eu-north-1", 4, 16)
2080
+ assert next(iter(pr)) == "summary" and pr["pr_text"]
2081
+ off = await _tool("offhours_check")(provider="aws", region="us-east-1", vcpu=4, ram_gb=16, hours_per_week_on=50)
2082
+ assert {"monthly_usd_saving", "limitations"} <= set(off)
2083
+ assert "error" in await _tool("offhours_check")(provider="aws", region="us-east-1", vcpu=4, ram_gb=16, hours_per_week_on=200)
2084
+ assert "error" in await _tool("rightsizing_check")(provider="aws", region="us-east-1", vcpu=8, ram_gb=32, cpu_utilization=15)
2085
+
2086
+
2087
+ @pytest.mark.asyncio
2088
+ async def test_reverse_pr_text_does_not_repeat_backfire():
2089
+ from greenpick.services import justification_service as js
2090
+ r = await js.region_move_justification(MockCarbonRepository(), "aws", "eu-north-1", "aws", "us-east-1",
2091
+ 4, 16, monthly_cross_boundary_gb=100)
2092
+ assert "would RAISE" in r["pr_text"] and "backfires" not in r["pr_text"]
2093
+ assert "unless the data moves too" not in r["pr_text"] and "on top." in r["pr_text"]
File without changes
File without changes