Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@

All notable changes to the OilPriceAPI Python SDK will be documented in this file.

## [1.12.7] - 2026-08-12

### Fixed

- Removed a nonexistent request-limit bonus claim from sync and async
usage-attribution header comments.
- Added red-first recursive authored and installed-wheel claim coverage so
telemetry or application metadata cannot be presented as changing account
entitlements.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

Expand Down Expand Up @@ -151,7 +161,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Data Sources Resource**: `client.data_sources.list()`, `get()`, `create()`, `update()`, `delete()`, `test()`, `logs()`, `health()`, `rotate_credentials()` for data connector management
- **Enhanced Alerts**: Added `test()`, `triggers()`, `analytics_history()` methods to existing alerts resource
- **Data Connector Support**: `client.get_data_connector_prices()` for BYOS (Bring Your Own Subscription) prices
- **Telemetry Headers**: `app_url` and `app_name` parameters for API usage attribution (10% rate limit bonus for app_url)
- **Telemetry Headers**: `app_url` and `app_name` parameters for API usage attribution

### Fixed

Expand Down
2 changes: 1 addition & 1 deletion oilpriceapi/async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ def __init__(
"X-Client-Type": "sdk",
}

# Add optional telemetry headers (10% bonus for app_url!)
# Add optional usage-attribution headers.
if self.app_url:
self.headers["X-App-URL"] = self.app_url
if self.app_name:
Expand Down
2 changes: 1 addition & 1 deletion oilpriceapi/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ def __init__(
"X-Client-Type": "sdk",
}

# Add optional telemetry headers (10% bonus for app_url!)
# Add optional usage-attribution headers.
if self.app_url:
self.headers["X-App-URL"] = self.app_url
if self.app_name:
Expand Down
2 changes: 1 addition & 1 deletion oilpriceapi/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@
Used in __init__.py, client.py, and async_client.py.
"""

__version__ = "1.12.6"
__version__ = "1.12.7"
SDK_VERSION = __version__
SDK_NAME = "oilpriceapi-python"
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "oilpriceapi"
version = "1.12.6"
version = "1.12.7"
description = "Official Python SDK for source-timestamped OilPriceAPI energy data"
authors = [
{name = "OilPriceAPI", email = "support@oilpriceapi.com"}
Expand Down
83 changes: 83 additions & 0 deletions scripts/validate_storefront_claims.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,39 @@
_HTML_TAG_PATTERN = re.compile(r"<[^>]{1,500}>")
_MAX_ACTION_COUNT_GAP = 64
_MAX_RATE_SPAN = 200
_MAX_TELEMETRY_REWARD_SPAN = 320
_TELEMETRY_IDENTITY_PATTERN = re.compile(
r"\b(?:telemetry|app(?:lication)?[- ]+(?:metadata|url|name)|"
r"app[_ -]?url|app[_ -]?name|x-app-(?:url|name))\b",
re.IGNORECASE,
)
_TELEMETRY_STRONG_REWARD_PATTERN = re.compile(
r"\b(?:bonus|increase(?:s|d)?|unlock(?:s|ed)?|"
r"earn(?:s|ed)?|grant(?:s|ed)?|reward(?:s|ed)?|boost(?:s|ed)?)\b",
re.IGNORECASE,
)
_TELEMETRY_MODIFIER_REWARD_PATTERN = re.compile(
r"\b(?:more|extra|additional)\b", re.IGNORECASE
)
_TELEMETRY_QUOTA_SIGNAL_PATTERN = re.compile(
r"\b(?:api[- ]+)?(?:requests?|calls?|quota|limits?|allowances?|credits?)\b|"
r"(?<![\w.])\d+(?:\.\d+)?\s*%",
re.IGNORECASE,
)
_TELEMETRY_MODIFIER_GAP_WORDS = {
"account",
"annual",
"api",
"call",
"daily",
"hourly",
"monthly",
"quota",
"rate",
"request",
"usage",
}
_MAX_STRONG_REWARD_SPAN = 160
BLOCKED: Sequence[Tuple[str, Pattern[str]]] = (
("real-time claim", re.compile(r"\breal[ -]?time\b", re.IGNORECASE)),
(
Expand Down Expand Up @@ -230,6 +263,52 @@ def _fixed_rate_claims(text: str) -> List[str]:
return claims


def _telemetry_reward_claims(text: str) -> List[str]:
"""Find attribution identity + reward + quota signals in one bounded sentence."""
claims: List[str] = []
seen: Set[Tuple[int, str]] = set()

for segment_offset, segment in _bounded_rate_segments(text):
searchable = _HTML_TAG_PATTERN.sub(" ", segment)
identities = list(_TELEMETRY_IDENTITY_PATTERN.finditer(searchable))
quota_signals = list(_TELEMETRY_QUOTA_SIGNAL_PATTERN.finditer(searchable))
strong_rewards = list(_TELEMETRY_STRONG_REWARD_PATTERN.finditer(searchable))
modifier_rewards = list(_TELEMETRY_MODIFIER_REWARD_PATTERN.finditer(searchable))
reward_pairs: List[Tuple[int, int]] = []
for reward in strong_rewards:
for quota_signal in quota_signals:
start = min(reward.start(), quota_signal.start())
end = max(reward.end(), quota_signal.end())
if end - start <= _MAX_STRONG_REWARD_SPAN:
reward_pairs.append((start, end))
for reward in modifier_rewards:
for quota_signal in quota_signals:
if reward.end() > quota_signal.start():
continue
gap = searchable[reward.end() : quota_signal.start()]
gap_words = re.findall(r"[a-z]+", gap.lower())
if len(gap) <= 48 and all(
word in _TELEMETRY_MODIFIER_GAP_WORDS for word in gap_words
):
reward_pairs.append((reward.start(), quota_signal.end()))
for identity in identities:
candidates = [
(min(identity.start(), start), max(identity.end(), end))
for start, end in reward_pairs
if max(identity.end(), end) - min(identity.start(), start)
<= _MAX_TELEMETRY_REWARD_SPAN
]
if not candidates:
continue
start, end = min(candidates, key=lambda span: span[1] - span[0])
claim = re.sub(r"\s+", " ", searchable[start:end]).strip()
key = (segment_offset + start, claim)
if key not in seen:
seen.add(key)
claims.append(claim)
return claims


def _claim_failures(root: Path, surfaces: Iterable[Path]) -> List[str]:
failures: List[str] = []
for path in surfaces:
Expand All @@ -243,6 +322,10 @@ def _claim_failures(root: Path, surfaces: Iterable[Path]) -> List[str]:
failures.append(
f"{path.relative_to(root)}: fixed demo rate matched {claim!r}"
)
for claim in _telemetry_reward_claims(text):
failures.append(
f"{path.relative_to(root)}: telemetry quota reward matched {claim!r}"
)
return failures


Expand Down
2 changes: 1 addition & 1 deletion tests/test_release_readiness.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ def test_package_version_helper_reads_the_project_version() -> None:
capture_output=True,
text=True,
)
assert result.stdout.strip() == "1.12.6"
assert result.stdout.strip() == "1.12.7"


def test_every_workflow_pins_actions_and_hardens_each_checkout_step() -> None:
Expand Down
66 changes: 66 additions & 0 deletions tests/test_storefront_claims.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,24 @@ def _installed_text_failures(tmp_path: Path, text: str) -> List[str]:
return validate_package(tmp_path)


def _authored_text_failures(tmp_path: Path, text: str) -> List[str]:
package = tmp_path / "oilpriceapi" / "future"
package.mkdir(parents=True)
(tmp_path / "README.md").write_text(
"https://api.oilpriceapi.com/product-facts.json\n"
)
(tmp_path / "EXAMPLES.md").write_text("Reviewed examples.\n")
(tmp_path / "CHANGELOG.md").write_text("Reviewed history.\n")
(tmp_path / "pyproject.toml").write_text(
'[project]\nname = "oilpriceapi"\nversion = "9.9.9"\n'
)
(tmp_path / "oilpriceapi" / "version.py").write_text(
'__version__ = "9.9.9"\n'
)
(package / "types.pyi").write_text(text)
return validate(tmp_path)


def test_storefront_claims_match_reviewed_contract() -> None:
assert validate() == []

Expand Down Expand Up @@ -142,6 +160,54 @@ def test_rejects_claim_in_future_installed_package_data(tmp_path: Path) -> None:
)


def test_rejects_telemetry_quota_reward_in_future_nested_authored_source(
tmp_path: Path,
) -> None:
failures = _authored_text_failures(
tmp_path,
'"""Application telemetry unlocks additional API calls for your app."""\n',
)

assert any(
"oilpriceapi/future/types.pyi" in failure
and "telemetry quota reward" in failure
for failure in failures
), failures


@pytest.mark.parametrize(
"claim",
[
"Add optional telemetry headers (10% bonus for app_url!).",
"App telemetry may unlock a 10% bonus to your request limit.",
"X-App-URL earns extra request credits.",
"More requests are granted when application metadata is sent.",
"Sending app_url increases your quota allowance.",
],
)
def test_rejects_telemetry_quota_rewards_in_future_wheel_text(
tmp_path: Path, claim: str
) -> None:
failures = _installed_text_failures(tmp_path, claim)

assert any("telemetry quota reward" in failure for failure in failures), failures


@pytest.mark.parametrize(
"text",
[
"Optional telemetry headers identify SDK usage.",
"Application metadata supports usage attribution; entitlements come from Product Facts.",
"X-App-URL and X-App-Name are optional attribution headers.",
"Telemetry sends extra application metadata with API requests.",
],
)
def test_allows_telemetry_attribution_without_a_quota_reward(
tmp_path: Path, text: str
) -> None:
assert _installed_text_failures(tmp_path, text) == []


@pytest.mark.parametrize(
"claim",
[
Expand Down