aiosolaredge-one 0.2.0__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.
@@ -0,0 +1,27 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ concurrency:
9
+ group: ci-${{ github.ref }}
10
+ cancel-in-progress: true
11
+
12
+ jobs:
13
+ library:
14
+ name: Library (aiosolaredge-one)
15
+ runs-on: ubuntu-latest
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+ - uses: actions/setup-python@v5
19
+ with:
20
+ python-version: "3.13"
21
+ - run: pip install -e ".[dev]"
22
+ - name: Ruff
23
+ run: ruff check src tests
24
+ - name: Mypy (strict)
25
+ run: mypy src
26
+ - name: Pytest
27
+ run: pytest -q
@@ -0,0 +1,61 @@
1
+ name: Publish aiosolaredge-one
2
+
3
+ # Trusted Publishing (OIDC) — no API tokens stored anywhere.
4
+ # * Manual run (workflow_dispatch) -> TestPyPI (environment: testpypi)
5
+ # * GitHub Release published -> PyPI (environment: pypi)
6
+ # One-time PyPI/TestPyPI setup is documented in RELEASING.md.
7
+
8
+ on:
9
+ workflow_dispatch:
10
+ release:
11
+ types: [published]
12
+
13
+ jobs:
14
+ build:
15
+ name: Build distributions
16
+ runs-on: ubuntu-latest
17
+ steps:
18
+ - uses: actions/checkout@v4
19
+ - uses: actions/setup-python@v5
20
+ with:
21
+ python-version: "3.13"
22
+ - run: pip install build
23
+ - run: python -m build
24
+ - name: Validate metadata
25
+ run: pip install twine && python -m twine check dist/*
26
+ - uses: actions/upload-artifact@v4
27
+ with:
28
+ name: dist
29
+ path: dist/
30
+
31
+ testpypi:
32
+ name: Publish to TestPyPI
33
+ if: github.event_name == 'workflow_dispatch'
34
+ needs: build
35
+ runs-on: ubuntu-latest
36
+ environment: testpypi
37
+ permissions:
38
+ id-token: write
39
+ steps:
40
+ - uses: actions/download-artifact@v4
41
+ with:
42
+ name: dist
43
+ path: dist/
44
+ - uses: pypa/gh-action-pypi-publish@release/v1
45
+ with:
46
+ repository-url: https://test.pypi.org/legacy/
47
+
48
+ pypi:
49
+ name: Publish to PyPI
50
+ if: github.event_name == 'release'
51
+ needs: build
52
+ runs-on: ubuntu-latest
53
+ environment: pypi
54
+ permissions:
55
+ id-token: write
56
+ steps:
57
+ - uses: actions/download-artifact@v4
58
+ with:
59
+ name: dist
60
+ path: dist/
61
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,21 @@
1
+ # Secrets / local env
2
+ .env
3
+ *.env
4
+
5
+ # Claude Code local settings — may contain secrets in the permission allow-list
6
+ .claude/settings.local.json
7
+
8
+ # Unredacted API captures — never commit
9
+ fixtures/_raw/
10
+
11
+ # Python
12
+ .venv/
13
+ .venv-test/
14
+ *.egg-info/
15
+ dist/
16
+ build/
17
+ __pycache__/
18
+ *.pyc
19
+ .pytest_cache/
20
+ .mypy_cache/
21
+ .ruff_cache/
@@ -0,0 +1,22 @@
1
+ # Changelog
2
+
3
+ All notable changes to `aiosolaredge-one` are documented here. The format is
4
+ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this
5
+ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [0.2.0] - 2026-08-24
8
+
9
+ ### Added
10
+ - `budget` module: credit-budget pacing math (`compute_interval`,
11
+ `plan_interval`, `BudgetPlan`, `project_month_end_usage`, `backoff_interval`,
12
+ `cpm_floor`) for remaining-budget-aware, self-correcting poll scheduling.
13
+
14
+ ## [0.1.0] - 2026-08-24
15
+
16
+ ### Added
17
+ - Initial async client for the SolarEdge ONE (Monitoring v2) API:
18
+ `get_sites`, `get_site_overview`, `get_devices`, `get_energy`, `get_power`,
19
+ `get_alerts`, `validate`.
20
+ - Typed models (`Site`, `SiteOverview`, `Device`, `TimeSeries`, …), typed
21
+ exceptions, `CreditLedger` local monthly accounting, and `RateLimit` header
22
+ parsing.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 aiosolaredge-one contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,102 @@
1
+ Metadata-Version: 2.5
2
+ Name: aiosolaredge-one
3
+ Version: 0.2.0
4
+ Summary: Async client for the SolarEdge ONE (Monitoring v2) API
5
+ Project-URL: Homepage, https://github.com/CathalOConnorRH/solaredge-v2
6
+ Project-URL: Repository, https://github.com/CathalOConnorRH/solaredge-v2
7
+ Project-URL: Issues, https://github.com/CathalOConnorRH/solaredge-v2/issues
8
+ Project-URL: Changelog, https://github.com/CathalOConnorRH/solaredge-v2/blob/main/CHANGELOG.md
9
+ Author: aiosolaredge-one contributors
10
+ License: MIT License
11
+
12
+ Copyright (c) 2026 aiosolaredge-one contributors
13
+
14
+ Permission is hereby granted, free of charge, to any person obtaining a copy
15
+ of this software and associated documentation files (the "Software"), to deal
16
+ in the Software without restriction, including without limitation the rights
17
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
18
+ copies of the Software, and to permit persons to whom the Software is
19
+ furnished to do so, subject to the following conditions:
20
+
21
+ The above copyright notice and this permission notice shall be included in all
22
+ copies or substantial portions of the Software.
23
+
24
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
25
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
26
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
27
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
28
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
29
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
30
+ SOFTWARE.
31
+ License-File: LICENSE
32
+ Keywords: async,home-assistant,monitoring,solar,solaredge
33
+ Classifier: Development Status :: 3 - Alpha
34
+ Classifier: Framework :: AsyncIO
35
+ Classifier: License :: OSI Approved :: MIT License
36
+ Classifier: Programming Language :: Python :: 3.11
37
+ Classifier: Programming Language :: Python :: 3.12
38
+ Classifier: Programming Language :: Python :: 3.13
39
+ Classifier: Typing :: Typed
40
+ Requires-Python: >=3.11
41
+ Requires-Dist: aiohttp>=3.9
42
+ Provides-Extra: dev
43
+ Requires-Dist: mypy>=1.8; extra == 'dev'
44
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
45
+ Requires-Dist: pytest>=8.0; extra == 'dev'
46
+ Requires-Dist: ruff>=0.4; extra == 'dev'
47
+ Provides-Extra: publish
48
+ Requires-Dist: build>=1.2; extra == 'publish'
49
+ Requires-Dist: twine>=5.0; extra == 'publish'
50
+ Description-Content-Type: text/markdown
51
+
52
+ # aiosolaredge-one
53
+
54
+ Async Python client for the **SolarEdge ONE** (Monitoring API v2) platform. It
55
+ powers the
56
+ [SolarEdge ONE Home Assistant integration](https://github.com/CathalOConnorRH/ha-solaredge-one),
57
+ which lives in its own repo and depends on this package.
58
+
59
+ Base URL: `https://monitoringapi.solaredge.com/v2` · Auth: `X-API-Key` header
60
+ (plus optional `X-Account-Key` for Fleet accounts).
61
+
62
+ > Status: **alpha**. Endpoint coverage and models are derived from Phase 0 live
63
+ > captures. Some query-param names and the storage/battery/environmental paths
64
+ > are still unconfirmed pending docs.
65
+
66
+ ## Install (dev)
67
+
68
+ ```bash
69
+ python3 -m venv .venv && source .venv/bin/activate
70
+ pip install -e ".[dev]"
71
+ pytest
72
+ ```
73
+
74
+ ## Usage
75
+
76
+ ```python
77
+ import aiohttp
78
+ from aiosolaredge_one import SolarEdgeOneClient
79
+
80
+ async with aiohttp.ClientSession() as session:
81
+ client = SolarEdgeOneClient(session, api_key="...")
82
+ sites = await client.get_sites()
83
+ overview = await client.get_site_overview(sites[0].site_id)
84
+ print(overview.production.total, overview.production.unit)
85
+ power = await client.get_power(sites[0].site_id)
86
+ print("current power:", power.latest_value, power.unit)
87
+ ```
88
+
89
+ ## What it covers (Phase 0-confirmed endpoints)
90
+
91
+ | Method | Endpoint |
92
+ |--------|----------|
93
+ | `get_sites()` | `GET /sites` |
94
+ | `get_site_overview(id)` | `GET /sites/{id}/overview` |
95
+ | `get_devices(id)` | `GET /sites/{id}/devices` |
96
+ | `get_energy(id, ...)` | `GET /sites/{id}/energy` |
97
+ | `get_power(id, ...)` | `GET /sites/{id}/power` |
98
+ | `get_alerts(id)` | `GET /sites/{id}/alerts` |
99
+
100
+ Rate limiting: the client exposes `client.rate_limit` (per-minute limit +
101
+ remaining, parsed from response headers) and an optional `CreditLedger` for
102
+ tracking the monthly quota locally (not exposed by the API).
@@ -0,0 +1,51 @@
1
+ # aiosolaredge-one
2
+
3
+ Async Python client for the **SolarEdge ONE** (Monitoring API v2) platform. It
4
+ powers the
5
+ [SolarEdge ONE Home Assistant integration](https://github.com/CathalOConnorRH/ha-solaredge-one),
6
+ which lives in its own repo and depends on this package.
7
+
8
+ Base URL: `https://monitoringapi.solaredge.com/v2` · Auth: `X-API-Key` header
9
+ (plus optional `X-Account-Key` for Fleet accounts).
10
+
11
+ > Status: **alpha**. Endpoint coverage and models are derived from Phase 0 live
12
+ > captures. Some query-param names and the storage/battery/environmental paths
13
+ > are still unconfirmed pending docs.
14
+
15
+ ## Install (dev)
16
+
17
+ ```bash
18
+ python3 -m venv .venv && source .venv/bin/activate
19
+ pip install -e ".[dev]"
20
+ pytest
21
+ ```
22
+
23
+ ## Usage
24
+
25
+ ```python
26
+ import aiohttp
27
+ from aiosolaredge_one import SolarEdgeOneClient
28
+
29
+ async with aiohttp.ClientSession() as session:
30
+ client = SolarEdgeOneClient(session, api_key="...")
31
+ sites = await client.get_sites()
32
+ overview = await client.get_site_overview(sites[0].site_id)
33
+ print(overview.production.total, overview.production.unit)
34
+ power = await client.get_power(sites[0].site_id)
35
+ print("current power:", power.latest_value, power.unit)
36
+ ```
37
+
38
+ ## What it covers (Phase 0-confirmed endpoints)
39
+
40
+ | Method | Endpoint |
41
+ |--------|----------|
42
+ | `get_sites()` | `GET /sites` |
43
+ | `get_site_overview(id)` | `GET /sites/{id}/overview` |
44
+ | `get_devices(id)` | `GET /sites/{id}/devices` |
45
+ | `get_energy(id, ...)` | `GET /sites/{id}/energy` |
46
+ | `get_power(id, ...)` | `GET /sites/{id}/power` |
47
+ | `get_alerts(id)` | `GET /sites/{id}/alerts` |
48
+
49
+ Rate limiting: the client exposes `client.rate_limit` (per-minute limit +
50
+ remaining, parsed from response headers) and an optional `CreditLedger` for
51
+ tracking the monthly quota locally (not exposed by the API).
@@ -0,0 +1,83 @@
1
+ # Releasing `aiosolaredge-one`
2
+
3
+ This package is published to PyPI with **Trusted Publishing** (OIDC) — GitHub
4
+ Actions authenticates to PyPI directly, so there are **no API tokens** stored in
5
+ the repo or in GitHub secrets.
6
+
7
+ Workflow: [`.github/workflows/publish.yml`](.github/workflows/publish.yml)
8
+ - **Manual run** (`workflow_dispatch`) → **TestPyPI** (`testpypi` environment)
9
+ - **GitHub Release published** → **PyPI** (`pypi` environment)
10
+
11
+ ## 0. Prerequisites (once)
12
+
13
+ The repo is at **`CathalOConnorRH/solaredge-v2`** and `[project.urls]` in
14
+ [`pyproject.toml`](pyproject.toml) already point there. If you fork or rename,
15
+ update those URLs and the trusted-publisher config below to match.
16
+
17
+ ## 1. One-time Trusted Publisher config
18
+
19
+ Do this on **both** TestPyPI and PyPI. You do **not** need to create the project
20
+ first — use a "pending" publisher and it's created on first upload.
21
+
22
+ ### TestPyPI
23
+ 1. Sign in at <https://test.pypi.org> → *Your account* → *Publishing*.
24
+ 2. Under **Add a new pending publisher**, enter:
25
+ - **PyPI Project Name:** `aiosolaredge-one`
26
+ - **Owner:** `CathalOConnorRH`
27
+ - **Repository name:** `solaredge-v2`
28
+ - **Workflow name:** `publish.yml`
29
+ - **Environment name:** `testpypi`
30
+
31
+ ### PyPI
32
+ Repeat at <https://pypi.org> with the **same** values except:
33
+ - **Environment name:** `pypi`
34
+
35
+ ### GitHub environments (optional but recommended)
36
+ In the GitHub repo → *Settings* → *Environments*, create `testpypi` and `pypi`.
37
+ Add required reviewers on `pypi` if you want a manual approval gate before a real
38
+ release upload.
39
+
40
+ ## 2. Dry run to TestPyPI
41
+
42
+ GitHub → *Actions* → **Publish aiosolaredge-one** → **Run workflow** (on `main`).
43
+ This builds, `twine check`s, and uploads to TestPyPI. Verify the install:
44
+
45
+ ```bash
46
+ python -m venv /tmp/veitest && /tmp/veitest/bin/pip install \
47
+ --index-url https://test.pypi.org/simple/ \
48
+ --extra-index-url https://pypi.org/simple/ \
49
+ aiosolaredge-one==0.2.0
50
+ /tmp/veitest/bin/python -c "import aiosolaredge_one, sys; print(aiosolaredge_one.__version__)"
51
+ ```
52
+
53
+ (The `--extra-index-url` lets TestPyPI resolve real deps like `aiohttp`.)
54
+
55
+ ## 3. Release to PyPI
56
+
57
+ 1. Bump `version` in `pyproject.toml` **and** `src/aiosolaredge_one/__init__.py`
58
+ (`__version__`), add a `CHANGELOG.md` entry, commit.
59
+ 2. Tag and create a GitHub Release:
60
+ ```bash
61
+ git tag v0.2.0 && git push origin v0.2.0
62
+ gh release create v0.2.0 --title v0.2.0 --notes-from-tag
63
+ ```
64
+ 3. Publishing the release triggers the `pypi` job automatically.
65
+
66
+ Keep the release tag (`vX.Y.Z`) in sync with the package version. The HA
67
+ integration lives in its own repo
68
+ ([`ha-solaredge-one`](https://github.com/CathalOConnorRH/ha-solaredge-one)) and
69
+ pins `aiosolaredge-one==<version>` in its `manifest.json`; after publishing a new
70
+ library version, bump that pin (and the integration `version`) there so a
71
+ released integration always installs a published library.
72
+
73
+ ## Manual fallback (if not using CI)
74
+
75
+ Trusted Publishing is preferred, but you can upload from a laptop with an API
76
+ token:
77
+
78
+ ```bash
79
+ python -m pip install ".[publish]"
80
+ rm -rf dist && python -m build && python -m twine check dist/*
81
+ python -m twine upload --repository testpypi dist/* # dry run
82
+ python -m twine upload dist/* # real PyPI
83
+ ```
@@ -0,0 +1,60 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "aiosolaredge-one"
7
+ version = "0.2.0"
8
+ description = "Async client for the SolarEdge ONE (Monitoring v2) API"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = { file = "LICENSE" }
12
+ authors = [{ name = "aiosolaredge-one contributors" }]
13
+ keywords = ["solaredge", "solar", "monitoring", "home-assistant", "async"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Framework :: AsyncIO",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3.11",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Programming Language :: Python :: 3.13",
21
+ "Typing :: Typed",
22
+ ]
23
+ dependencies = ["aiohttp>=3.9"]
24
+
25
+ [project.optional-dependencies]
26
+ dev = [
27
+ "pytest>=8.0",
28
+ "pytest-asyncio>=0.23",
29
+ "mypy>=1.8",
30
+ "ruff>=0.4",
31
+ ]
32
+ publish = [
33
+ "build>=1.2",
34
+ "twine>=5.0",
35
+ ]
36
+
37
+ [project.urls]
38
+ Homepage = "https://github.com/CathalOConnorRH/solaredge-v2"
39
+ Repository = "https://github.com/CathalOConnorRH/solaredge-v2"
40
+ Issues = "https://github.com/CathalOConnorRH/solaredge-v2/issues"
41
+ Changelog = "https://github.com/CathalOConnorRH/solaredge-v2/blob/main/CHANGELOG.md"
42
+
43
+ [tool.hatch.build.targets.wheel]
44
+ packages = ["src/aiosolaredge_one"]
45
+
46
+ [tool.pytest.ini_options]
47
+ asyncio_mode = "auto"
48
+ testpaths = ["tests"]
49
+
50
+ [tool.ruff]
51
+ line-length = 100
52
+ target-version = "py311"
53
+
54
+ [tool.ruff.lint]
55
+ select = ["E", "F", "I", "UP", "B", "SIM"]
56
+
57
+ [tool.mypy]
58
+ python_version = "3.11"
59
+ strict = true
60
+ warn_unused_ignores = true
@@ -0,0 +1,66 @@
1
+ """Async client for the SolarEdge ONE (Monitoring v2) API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .budget import (
6
+ BudgetPlan,
7
+ backoff_interval,
8
+ compute_interval,
9
+ plan_interval,
10
+ project_month_end_usage,
11
+ )
12
+ from .client import SolarEdgeOneClient
13
+ from .const import (
14
+ DEFAULT_BASE_URL,
15
+ RESOLUTION_DAY,
16
+ RESOLUTION_HOUR,
17
+ RESOLUTION_QUARTER_HOUR,
18
+ )
19
+ from .exceptions import (
20
+ SolarEdgeApiError,
21
+ SolarEdgeAuthError,
22
+ SolarEdgeConnectionError,
23
+ SolarEdgeError,
24
+ SolarEdgeNotFoundError,
25
+ SolarEdgeRateLimitError,
26
+ )
27
+ from .models import (
28
+ ConsumptionOverview,
29
+ Device,
30
+ ProductionOverview,
31
+ Site,
32
+ SiteOverview,
33
+ TimeSeries,
34
+ TimeValue,
35
+ )
36
+ from .ratelimit import CreditLedger, RateLimit
37
+
38
+ __version__ = "0.2.0"
39
+
40
+ __all__ = [
41
+ "SolarEdgeOneClient",
42
+ "BudgetPlan",
43
+ "backoff_interval",
44
+ "compute_interval",
45
+ "plan_interval",
46
+ "project_month_end_usage",
47
+ "DEFAULT_BASE_URL",
48
+ "RESOLUTION_DAY",
49
+ "RESOLUTION_HOUR",
50
+ "RESOLUTION_QUARTER_HOUR",
51
+ "SolarEdgeError",
52
+ "SolarEdgeApiError",
53
+ "SolarEdgeAuthError",
54
+ "SolarEdgeConnectionError",
55
+ "SolarEdgeNotFoundError",
56
+ "SolarEdgeRateLimitError",
57
+ "Site",
58
+ "SiteOverview",
59
+ "ProductionOverview",
60
+ "ConsumptionOverview",
61
+ "Device",
62
+ "TimeSeries",
63
+ "TimeValue",
64
+ "CreditLedger",
65
+ "RateLimit",
66
+ ]
@@ -0,0 +1,145 @@
1
+ """Credit-budget pacing math (HA-agnostic, pure functions).
2
+
3
+ The SolarEdge ONE API exposes only a per-minute rate limit; the monthly credit
4
+ quota (e.g. 2000/month) is tracked locally via :class:`CreditLedger`. This module
5
+ turns "credits remaining this month" into "how long to wait before the next poll"
6
+ so a caller spends its budget evenly instead of burning it early.
7
+
8
+ The core function, :func:`compute_interval`, is *remaining-budget aware*: it
9
+ paces against the credits still available for the rest of the month, so it
10
+ self-corrects. If earlier cycles overspent, later intervals lengthen; if the
11
+ budget is nearly exhausted it backs off to ``max_interval``. This doubles as the
12
+ budget guard — no separate "are we over budget?" branch is needed for pacing.
13
+
14
+ All functions are pure (no clock, no I/O) so they can be unit-tested by feeding
15
+ in ``remaining_credits`` / ``seconds_until_reset`` directly.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from dataclasses import dataclass
21
+
22
+ # Sensible defaults; the HA integration overrides these from config/options.
23
+ DEFAULT_SAFETY_FACTOR = 0.7
24
+ DEFAULT_NIGHT_FACTOR = 3.0
25
+ DEFAULT_MIN_INTERVAL = 60.0 # seconds — never poll faster than once a minute
26
+ DEFAULT_MAX_INTERVAL = 6 * 60 * 60.0 # seconds — poll at least every 6h
27
+
28
+
29
+ def _clamp(value: float, low: float, high: float) -> float:
30
+ if low > high:
31
+ # A pathological config (floor above ceiling); prefer respecting the
32
+ # per-minute floor over the ceiling to avoid violating the hard limit.
33
+ return low
34
+ return max(low, min(high, value))
35
+
36
+
37
+ def cpm_floor(credits_per_cycle: int, calls_per_minute: int) -> float:
38
+ """Minimum seconds between cycles so we never exceed the per-minute limit.
39
+
40
+ A cycle issues ``credits_per_cycle`` calls; spacing cycles at least this far
41
+ apart keeps the average call rate at or below ``calls_per_minute``.
42
+ """
43
+ cpm = max(1, calls_per_minute)
44
+ return 60.0 * max(1, credits_per_cycle) / cpm
45
+
46
+
47
+ @dataclass(slots=True, frozen=True)
48
+ class BudgetPlan:
49
+ """User-configurable pacing parameters for one config entry."""
50
+
51
+ monthly_budget: int
52
+ calls_per_minute: int = 10
53
+ credits_per_cycle: int = 2
54
+ safety_factor: float = DEFAULT_SAFETY_FACTOR
55
+ night_factor: float = DEFAULT_NIGHT_FACTOR
56
+ min_interval: float = DEFAULT_MIN_INTERVAL
57
+ max_interval: float = DEFAULT_MAX_INTERVAL
58
+
59
+ @property
60
+ def target_credits(self) -> float:
61
+ """Credits we actually aim to spend per month (budget × safety)."""
62
+ return self.monthly_budget * self.safety_factor
63
+
64
+
65
+ def compute_interval(
66
+ *,
67
+ remaining_credits: float,
68
+ seconds_until_reset: float,
69
+ credits_per_cycle: int,
70
+ calls_per_minute: int,
71
+ min_interval: float = DEFAULT_MIN_INTERVAL,
72
+ max_interval: float = DEFAULT_MAX_INTERVAL,
73
+ is_night: bool = False,
74
+ night_factor: float = DEFAULT_NIGHT_FACTOR,
75
+ ) -> float:
76
+ """Seconds to wait before the next poll, paced to the remaining budget.
77
+
78
+ ``remaining_credits`` should already have the safety factor applied (i.e. it
79
+ is the *target* remaining, not the raw quota). When it drops below one
80
+ cycle's cost — or the month has effectively ended — this returns
81
+ ``max_interval`` so polling backs off instead of overspending.
82
+ """
83
+ floor = max(min_interval, cpm_floor(credits_per_cycle, calls_per_minute))
84
+ if remaining_credits < credits_per_cycle or seconds_until_reset <= 0:
85
+ return max(max_interval, floor)
86
+
87
+ cycles_left = remaining_credits / max(1, credits_per_cycle)
88
+ interval = seconds_until_reset / cycles_left
89
+ if is_night:
90
+ interval *= night_factor
91
+ return _clamp(interval, floor, max_interval)
92
+
93
+
94
+ def plan_interval(
95
+ plan: BudgetPlan,
96
+ *,
97
+ used_this_month: int,
98
+ seconds_until_reset: float,
99
+ is_night: bool = False,
100
+ ) -> float:
101
+ """Convenience wrapper: interval for a :class:`BudgetPlan` given usage."""
102
+ remaining = max(0.0, plan.target_credits - used_this_month)
103
+ return compute_interval(
104
+ remaining_credits=remaining,
105
+ seconds_until_reset=seconds_until_reset,
106
+ credits_per_cycle=plan.credits_per_cycle,
107
+ calls_per_minute=plan.calls_per_minute,
108
+ min_interval=plan.min_interval,
109
+ max_interval=plan.max_interval,
110
+ is_night=is_night,
111
+ night_factor=plan.night_factor,
112
+ )
113
+
114
+
115
+ def project_month_end_usage(
116
+ *, used_this_month: int, elapsed_seconds: float, total_seconds: float
117
+ ) -> float:
118
+ """Linear projection of end-of-month spend from usage so far.
119
+
120
+ Used to decide whether to raise a "you'll blow the budget" repair issue.
121
+ Returns the current usage unchanged until a little time has elapsed (avoids
122
+ wild extrapolation from the first few seconds of the month).
123
+ """
124
+ if elapsed_seconds <= 0 or total_seconds <= 0:
125
+ return float(used_this_month)
126
+ return used_this_month * total_seconds / elapsed_seconds
127
+
128
+
129
+ def backoff_interval(
130
+ attempt: int,
131
+ *,
132
+ base: float = 60.0,
133
+ cap: float = 60 * 60.0,
134
+ retry_after: float | None = None,
135
+ ) -> float:
136
+ """Exponential backoff after a 429, honouring ``Retry-After`` when present.
137
+
138
+ ``attempt`` is 1-based (first failure → ``base``). The result is capped at
139
+ ``cap`` and never shorter than a supplied ``Retry-After``.
140
+ """
141
+ exp = base * (2.0 ** max(0, attempt - 1))
142
+ interval = min(exp, cap)
143
+ if retry_after is not None:
144
+ interval = max(interval, retry_after)
145
+ return interval