-
⚡ Real-Time Prices
-
Latest spot prices for Brent, WTI, Natural Gas, Coal, and more. Updated every 15 minutes.
+
⚡ Source-Timestamped Prices
+
Latest available spot records include source and timestamp context for freshness decisions.
diff --git a/docs/index.md b/docs/index.md
index 1c79986..1650f7d 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -1,6 +1,6 @@
# OilPriceAPI Python SDK Documentation
-Welcome to the official Python SDK for [OilPriceAPI](https://oilpriceapi.com) - the most affordable way to access professional-grade oil and commodity price data.
+Welcome to the official Python SDK for [OilPriceAPI](https://oilpriceapi.com), providing source-timestamped oil and commodity data.
## 🚀 Getting Started
@@ -33,9 +33,9 @@ print(f"Brent Crude: ${price.value:.2f}")
## 📚 Core Features
-### Real-Time Price Data
-
-Get the latest commodity prices updated every 5 minutes:
+### Current Price Data
+
+Get the latest available commodity prices with API-provided source timestamps:
```python
# Single commodity
@@ -119,7 +119,7 @@ prices = asyncio.run(get_all_prices())
## 🎯 Use Cases
### Energy Trading
-Build algorithmic trading strategies with real-time price feeds and historical data for backtesting.
+Build algorithmic trading strategies with current and historical data while retaining source timestamps for backtesting.
**[Explore trading examples →](https://oilpriceapi.com/use-cases/trading)**
@@ -213,31 +213,15 @@ commodity suggestions, plan or feature requirements, retry metadata, sanitized
response headers, and raw diagnostics remain available without exposing the
configured API key.
-## 💰 Pricing & Plans
-
-Choose the plan that fits your needs:
-
-### Free Tier
-- 1,000 API requests/month
-- Real-time data
-- No credit card required
-
-**[Start free →](https://oilpriceapi.com/auth/signup)**
-
-### Paid Plans
-- **Developer**: $19/month - 10,000 requests
-- **Starter**: $49/month - 50,000 requests (adds webhooks)
-- **Professional**: $99/month - 100,000 requests (adds webhooks + WebSocket streaming)
-- **Scale**: $299/month - 1,000,000 requests
-
-**All plans include:**
-- ✅ Real-time price updates every 5 minutes
-- ✅ Historical data access
-- ✅ 99.9% uptime SLA
-- ✅ Email support
-- ✅ No hidden fees
-
-**[View detailed pricing →](https://oilpriceapi.com/pricing)**
+## 💰 Access & Plans
+
+Dataset access, allowances, and feature availability depend on the current
+account entitlement. Review the [current pricing](https://oilpriceapi.com/pricing)
+and the machine-readable [product facts](https://api.oilpriceapi.com/product-facts.json)
+instead of relying on values bundled into an SDK release. API responses retain
+the applicable source, observation timestamp, and limit metadata.
+
+**[Create an API key →](https://oilpriceapi.com/auth/signup)**
## 🛠️ Development
@@ -298,7 +282,7 @@ MIT License - see [LICENSE](https://github.com/OilpriceAPI/python-sdk/blob/main/
---
-**Ready to get started?** [Sign up for your free API key →](https://oilpriceapi.com/auth/signup)
+**Ready to get started?** [Create an API key →](https://oilpriceapi.com/auth/signup)
**Questions?** [Contact our support team →](mailto:support@oilpriceapi.com)
diff --git a/oilpriceapi/async_client.py b/oilpriceapi/async_client.py
index 8784191..f5fa8dc 100644
--- a/oilpriceapi/async_client.py
+++ b/oilpriceapi/async_client.py
@@ -155,7 +155,7 @@ def __init__(
# Agent watch subscriptions + event polling (#3245 Phase 2).
self.subscriptions = AsyncSubscriptionsResource(self)
- # Real-time WebSocket streaming namespace (requires the [stream] extra).
+ # WebSocket price-update namespace (requires the [stream] extra).
# Lazily imports `websockets` only when a stream is actually opened.
from .streaming import AsyncStreamNamespace
diff --git a/oilpriceapi/resources/diesel.py b/oilpriceapi/resources/diesel.py
index c8eec2a..db0108d 100644
--- a/oilpriceapi/resources/diesel.py
+++ b/oilpriceapi/resources/diesel.py
@@ -16,11 +16,11 @@ class DieselResource:
Provides access to state-level diesel price averages and station-level pricing.
Example:
- >>> # Get state average (free tier)
+ >>> # Get the available state average
>>> price = client.diesel.get_price("CA")
>>> print(f"California diesel: ${price.price:.2f}/gallon")
- >>> # Get nearby stations (paid tiers)
+ >>> # Get nearby stations when enabled for the current account
>>> result = client.diesel.get_stations(lat=37.7749, lng=-122.4194)
>>> print(f"Found {len(result.stations)} stations")
"""
@@ -36,8 +36,8 @@ def __init__(self, client):
def get_price(self, state: str) -> DieselPrice:
"""Get average diesel price for a US state.
- Returns EIA state-level average diesel price. This endpoint is free
- and included in all tiers.
+ Returns the available EIA state-level average diesel price. Access and
+ request limits follow the account's current entitlement and API metadata.
Args:
state: Two-letter US state code (e.g., "CA", "TX", "NY")
@@ -105,15 +105,11 @@ def get_stations(
Returns station-level diesel prices within specified radius using Google Maps data.
- **Tier Requirements:** Available on paid tiers (Exploration and above)
-
- **Pricing Tiers:**
- - Exploration: 100 station queries/month
- - Starter: 500 station queries/month
- - Professional: 2,000 station queries/month
- - Business: 5,000 station queries/month
-
- **Caching:** Results are cached for 24 hours to minimize costs.
+ Station-level access and allowances depend on the account's current
+ entitlement. Review https://www.oilpriceapi.com/pricing and the API's
+ response metadata instead of relying on SDK-bundled limits.
+
+ Use the returned source timestamp to apply the application's freshness policy.
Args:
lat: Latitude (-90 to 90)
@@ -126,8 +122,8 @@ def get_stations(
Raises:
ValidationError: If coordinates or radius are invalid
AuthenticationError: If API key is invalid
- RateLimitError: If monthly station query limit exceeded (429)
- OilPriceAPIError: If tier doesn't support station queries (403)
+ RateLimitError: If the API reports the request limit exceeded (429)
+ OilPriceAPIError: If the account cannot access station queries (403)
Example:
>>> # Get stations near San Francisco
diff --git a/oilpriceapi/streaming/__init__.py b/oilpriceapi/streaming/__init__.py
index 3b996bc..0483fe4 100644
--- a/oilpriceapi/streaming/__init__.py
+++ b/oilpriceapi/streaming/__init__.py
@@ -1,5 +1,5 @@
"""
-Real-time WebSocket streaming for OilPriceAPI.
+WebSocket price-update streaming for OilPriceAPI.
Exposes an async streaming client over the Rails ActionCable ``/cable``
endpoint (``EnergyPricesChannel``). Available via ``AsyncOilPriceAPI.stream``.
diff --git a/oilpriceapi/streaming/client.py b/oilpriceapi/streaming/client.py
index 7f0437b..6ebe55e 100644
--- a/oilpriceapi/streaming/client.py
+++ b/oilpriceapi/streaming/client.py
@@ -154,8 +154,8 @@ async def _subscribe(self) -> None:
return
if msg_type == "reject_subscription":
raise ConnectionError(
- "Subscription rejected — check your plan tier and API key "
- "(WebSocket streaming requires the Professional plan ($99/mo) or higher)."
+ "Subscription rejected; confirm the API key and streaming entitlement at "
+ "https://www.oilpriceapi.com/pricing."
)
# Ignore pings / pre-confirmation noise.
@@ -289,7 +289,7 @@ def prices(
reconnect_max_delay: float = 30.0,
open_timeout: float = 10.0,
) -> PriceStream:
- """Open a real-time price stream over ``EnergyPricesChannel``.
+ """Open a price-update stream over ``EnergyPricesChannel``.
Args:
commodities: Optional list of commodity codes to tag the
diff --git a/oilpriceapi/version.py b/oilpriceapi/version.py
index 20fb3af..690ef9a 100644
--- a/oilpriceapi/version.py
+++ b/oilpriceapi/version.py
@@ -5,6 +5,6 @@
Used in __init__.py, client.py, and async_client.py.
"""
-__version__ = "1.12.1"
+__version__ = "1.12.2"
SDK_VERSION = __version__
SDK_NAME = "oilpriceapi-python"
diff --git a/pyproject.toml b/pyproject.toml
index 7e42b9a..8d1702a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "oilpriceapi"
-version = "1.12.1"
+version = "1.12.2"
description = "Official Python SDK for source-timestamped OilPriceAPI energy data"
authors = [
{name = "OilPriceAPI", email = "support@oilpriceapi.com"}
diff --git a/scripts/clean-wheel-smoke.sh b/scripts/clean-wheel-smoke.sh
index 60c6f49..73a3a41 100755
--- a/scripts/clean-wheel-smoke.sh
+++ b/scripts/clean-wheel-smoke.sh
@@ -28,6 +28,11 @@ trap 'rm -rf "$smoke_dir"' EXIT
python -m venv "$smoke_dir/venv"
"$smoke_dir/venv/bin/python" -m pip install --quiet "$wheel"
"$smoke_dir/venv/bin/python" -m pip check
+site_packages="$(
+ "$smoke_dir/venv/bin/python" -c 'import site; print(site.getsitepackages()[0])'
+)"
+"$smoke_dir/venv/bin/python" "$root_dir/scripts/validate_storefront_claims.py" \
+ --package-root "$site_packages"
"$smoke_dir/venv/bin/python" -c '
import sys
from oilpriceapi import OilPriceAPI, __version__
diff --git a/scripts/validate_storefront_claims.py b/scripts/validate_storefront_claims.py
index ca4262b..c010813 100644
--- a/scripts/validate_storefront_claims.py
+++ b/scripts/validate_storefront_claims.py
@@ -1,42 +1,157 @@
#!/usr/bin/env python3
-"""Reject stale mutable claims from files rendered by PyPI and GitHub."""
+"""Reject stale mutable claims from authored, generated, and packaged surfaces."""
+import argparse
+import csv
import re
from pathlib import Path
-from typing import List
+from typing import Iterable, List, Pattern, Sequence, Tuple
ROOT = Path(__file__).resolve().parents[1]
-SURFACES = (
- ROOT / "README.md",
- ROOT / "pyproject.toml",
- ROOT / "oilpriceapi" / "__init__.py",
-)
-BLOCKED = (
- re.compile(r"\breal[ -]?time\b", re.IGNORECASE),
- re.compile(r"\b(?:110|200|500)\+\s+(?:commodit|endpoint|tool)", re.IGNORECASE),
- re.compile(r"\b2m\+?\s+api requests", re.IGNORECASE),
- re.compile(r"\b(?:every|updated|refresh(?:ed)?)\s+(?:in\s+)?5 minutes\b", re.IGNORECASE),
- re.compile(r"\b(?:99\.\d+%|fortune 500|trading[- ]grade)\b", re.IGNORECASE),
- re.compile(r"\b(?:1,000|100)\s+requests?(?:/month|\s+per month|\s+\(lifetime\))", re.IGNORECASE),
- re.compile(r"\bunlimited\s+(?:history|webhooks?|requests?|commodit)", re.IGNORECASE),
-)
CONTRACT = "https://api.oilpriceapi.com/product-facts.json"
+BINARY_SUFFIXES = {
+ ".a",
+ ".class",
+ ".dll",
+ ".dylib",
+ ".o",
+ ".pyd",
+ ".pyc",
+ ".pyo",
+ ".so",
+}
+BLOCKED: Sequence[Tuple[str, Pattern[str]]] = (
+ ("real-time claim", re.compile(r"\breal[ -]?time\b", re.IGNORECASE)),
+ (
+ "fixed catalog total",
+ re.compile(r"\b\d+\+\s+(?:commodit|endpoint|tool|api)", re.IGNORECASE),
+ ),
+ ("fixed traffic total", re.compile(r"\b2m\+?\s+api requests", re.IGNORECASE)),
+ (
+ "fixed update cadence",
+ re.compile(
+ r"\b(?:every|updated|refresh(?:ed)?)\s+(?:in\s+)?\d+\s+minutes\b",
+ re.IGNORECASE,
+ ),
+ ),
+ ("uptime or SLA", re.compile(r"\b\d+(?:\.\d+)?%\s+uptime\b|\bSLA\b", re.IGNORECASE)),
+ (
+ "price comparison",
+ re.compile(r"\bbloomberg\b|\b\d+(?:\.\d+)?%\s+less\s+cost\b", re.IGNORECASE),
+ ),
+ (
+ "unreviewed plan name",
+ re.compile(
+ r"\bprofessional(?:\+|\s+plan)\b|\bprofessional\*{0,2}\s*:|"
+ r"\bstarter plan\b|\bscale tier\b|\bpaid tiers?\b|"
+ r"\bexploration(?:\s+(?:plan|tier|and above))?\b",
+ re.IGNORECASE,
+ ),
+ ),
+ (
+ "unreviewed plan price",
+ re.compile(r"\$\d+(?:\.\d+)?\s*(?:/|per\s+)(?:mo(?:nth)?|year)\b", re.IGNORECASE),
+ ),
+ (
+ "fixed allowance",
+ re.compile(
+ r"\b\d[\d,]*\s+(?:free\s+)?(?:api\s+requests?|station\s+queries?)"
+ r"\s*(?:/|per\s+)month\b|"
+ r"\bmonthly\s+station\s+(?:query|request)\s+limit\b",
+ re.IGNORECASE,
+ ),
+ ),
+ (
+ "quota promise",
+ re.compile(
+ r"\bdoes\s+not\s+consume.{0,40}\bquota\b|"
+ r"\bunlimited\s+(?:history|webhooks?|requests?|commodit)",
+ re.IGNORECASE,
+ ),
+ ),
+ (
+ "free-tier claim",
+ re.compile(
+ r"\bfree\s+tier\b|\bfree\s+api\s+key\b|"
+ r"\b(?:endpoint|access)\s+is\s+free\b|\bincluded\s+in\s+all\s+tiers\b",
+ re.IGNORECASE,
+ ),
+ ),
+ (
+ "fixed demo rate",
+ re.compile(
+ r"\b\d+\s+(?:requests?|reqs?\.?)\s*(?:(?:per|an?)\s+|/\s*)"
+ r"(?:minutes?|mins?|hours?|hrs?|days?)\b",
+ re.IGNORECASE,
+ ),
+ ),
+)
+
+
+def discover_public_surfaces(root: Path = ROOT) -> List[Path]:
+ surfaces = [root / "README.md", root / "EXAMPLES.md", root / "pyproject.toml"]
+ for directory in (root / "docs", root / "oilpriceapi"):
+ surfaces.extend(path for path in directory.rglob("*") if _is_public_text(path))
+ return sorted(set(surfaces))
+
+def _is_public_text(path: Path) -> bool:
+ if not path.is_file() or path.suffix.lower() in BINARY_SUFFIXES:
+ return False
+ try:
+ path.read_text(encoding="utf-8")
+ except UnicodeDecodeError:
+ return False
+ return True
-def validate() -> List[str]:
- failures = []
- for path in SURFACES:
- text = path.read_text()
- for pattern in BLOCKED:
- if pattern.search(text):
- failures.append(f"{path.relative_to(ROOT)}: blocked claim matched {pattern.pattern}")
- readme = (ROOT / "README.md").read_text()
+def discover_installed_surfaces(package_root: Path) -> List[Path]:
+ """Return every UTF-8 customer-readable file recorded in the wheel manifest."""
+ package_root = package_root.resolve()
+ record_files = sorted(package_root.glob("oilpriceapi-*.dist-info/RECORD"))
+ if len(record_files) != 1:
+ return []
+
+ surfaces: List[Path] = []
+ with record_files[0].open(encoding="utf-8", newline="") as record:
+ for row in csv.reader(record):
+ if not row:
+ continue
+ path = (package_root / row[0]).resolve()
+ try:
+ path.relative_to(package_root)
+ except ValueError:
+ continue
+ if not _is_public_text(path):
+ continue
+ surfaces.append(path)
+ return sorted(set(surfaces))
+
+
+def _claim_failures(root: Path, surfaces: Iterable[Path]) -> List[str]:
+ failures: List[str] = []
+ for path in surfaces:
+ text = path.read_text(encoding="utf-8")
+ for label, pattern in BLOCKED:
+ match = pattern.search(text)
+ if match:
+ if label == "fixed demo rate" and match.group(0).lower() == "50 requests/day":
+ continue
+ failures.append(
+ f"{path.relative_to(root)}: {label} matched {match.group(0)!r}"
+ )
+ return failures
+
+
+def validate(root: Path = ROOT) -> List[str]:
+ failures = _claim_failures(root, discover_public_surfaces(root))
+
+ readme = (root / "README.md").read_text()
if CONTRACT not in readme:
failures.append("README.md: reviewed product-facts contract is not linked")
- project = (ROOT / "pyproject.toml").read_text()
- version_file = (ROOT / "oilpriceapi" / "version.py").read_text()
+ project = (root / "pyproject.toml").read_text()
+ version_file = (root / "oilpriceapi" / "version.py").read_text()
project_match = re.search(r'^version = "([^"]+)"', project, re.MULTILINE)
module_match = re.search(r'^__version__ = "([^"]+)"', version_file, re.MULTILINE)
if not project_match or not module_match or project_match.group(1) != module_match.group(1):
@@ -44,11 +159,45 @@ def validate() -> List[str]:
return failures
+def validate_package(package_root: Path) -> List[str]:
+ package_root = package_root.resolve()
+ package_dir = package_root / "oilpriceapi"
+ metadata_files = sorted(package_root.glob("oilpriceapi-*.dist-info/METADATA"))
+ record_files = sorted(package_root.glob("oilpriceapi-*.dist-info/RECORD"))
+ surfaces = discover_installed_surfaces(package_root)
+ failures = _claim_failures(package_root, surfaces)
+
+ if len(metadata_files) != 1:
+ failures.append("installed artifact must contain exactly one oilpriceapi METADATA file")
+ return failures
+ if len(record_files) != 1:
+ failures.append("installed artifact must contain exactly one oilpriceapi RECORD file")
+ return failures
+
+ metadata = metadata_files[0].read_text()
+ if CONTRACT not in metadata:
+ failures.append("installed METADATA: reviewed product-facts contract is not linked")
+
+ version_file = (package_dir / "version.py").read_text()
+ module_match = re.search(r'^__version__ = "([^"]+)"', version_file, re.MULTILINE)
+ metadata_match = re.search(r"^Version: ([^\s]+)$", metadata, re.MULTILINE)
+ if not module_match or not metadata_match or module_match.group(1) != metadata_match.group(1):
+ failures.append("installed METADATA version differs from oilpriceapi/version.py")
+ return failures
+
+
def main() -> None:
- failures = validate()
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--package-root", type=Path)
+ args = parser.parse_args()
+
+ failures = validate_package(args.package_root) if args.package_root else validate()
if failures:
raise SystemExit("\n".join(failures))
- print(f"validated {len(SURFACES)} Python storefront surfaces")
+ if args.package_root:
+ print("validated exact installed Python artifact claims")
+ else:
+ print(f"validated {len(discover_public_surfaces())} public surfaces")
if __name__ == "__main__":
diff --git a/tests/test_release_readiness.py b/tests/test_release_readiness.py
index 0cbf3bd..6bb911e 100644
--- a/tests/test_release_readiness.py
+++ b/tests/test_release_readiness.py
@@ -32,6 +32,7 @@ def test_publish_gate_audits_and_installs_the_built_wheel() -> None:
assert "scripts/clean-wheel-smoke.sh" in workflow
assert "continue-on-error: true" not in workflow
assert "from oilpriceapi.version import SDK_VERSION" not in smoke
+ assert "--package-root" in smoke
def test_packaging_configuration_remains_compatible_with_supported_python() -> None:
diff --git a/tests/test_storefront_claims.py b/tests/test_storefront_claims.py
index a50c640..911698d 100644
--- a/tests/test_storefront_claims.py
+++ b/tests/test_storefront_claims.py
@@ -1,5 +1,103 @@
-from scripts.validate_storefront_claims import validate
+from pathlib import Path
+
+from scripts.validate_storefront_claims import (
+ discover_installed_surfaces,
+ discover_public_surfaces,
+ validate,
+ validate_package,
+)
+
+ROOT = Path(__file__).resolve().parents[1]
def test_storefront_claims_match_reviewed_contract() -> None:
assert validate() == []
+
+
+def test_discovers_docs_examples_and_nested_package_source() -> None:
+ surfaces = {path.relative_to(ROOT).as_posix() for path in discover_public_surfaces()}
+
+ assert "EXAMPLES.md" in surfaces
+ assert "docs/index.md" in surfaces
+ assert "docs/index.html" in surfaces
+ assert "oilpriceapi/streaming/client.py" in surfaces
+
+
+def test_rejects_claim_introduced_only_in_installed_wheel(tmp_path: Path) -> None:
+ package = tmp_path / "oilpriceapi"
+ dist_info = tmp_path / "oilpriceapi-9.9.9.dist-info"
+ package.mkdir()
+ dist_info.mkdir()
+ (package / "version.py").write_text('__version__ = "9.9.9"\n')
+ (package / "future.py").write_text('"""Guaranteed 99.9% uptime."""\n')
+ (dist_info / "METADATA").write_text(
+ "Metadata-Version: 2.1\n"
+ "Name: oilpriceapi\n"
+ "Version: 9.9.9\n\n"
+ "https://api.oilpriceapi.com/product-facts.json\n"
+ )
+ (dist_info / "RECORD").write_text(
+ "oilpriceapi/version.py,,\n"
+ "oilpriceapi/future.py,,\n"
+ "oilpriceapi-9.9.9.dist-info/METADATA,,\n"
+ "oilpriceapi-9.9.9.dist-info/RECORD,,\n"
+ )
+
+ assert any("oilpriceapi/future.py" in failure for failure in validate_package(tmp_path))
+
+
+def test_rejects_claim_in_future_installed_package_data(tmp_path: Path) -> None:
+ package = tmp_path / "oilpriceapi"
+ dist_info = tmp_path / "oilpriceapi-9.9.9.dist-info"
+ package.mkdir()
+ (package / "docs").mkdir()
+ (package / "__pycache__").mkdir()
+ dist_info.mkdir()
+ (package / "version.py").write_text('__version__ = "9.9.9"\n')
+ (package / "py.typed").write_text("")
+ (package / "types.pyi").write_text(
+ '"""Endpoint is free and included in all tiers. Available on paid tiers. '
+ 'Monthly station query limit applies."""\n'
+ )
+ (package / "docs" / "catalog.json").write_text(
+ '{"allowance": "1,000 API requests/month"}\n'
+ )
+ (package / "__pycache__" / "version.cpython-312.pyc").write_bytes(b"\x00\xff")
+ (dist_info / "METADATA").write_text(
+ "Metadata-Version: 2.1\n"
+ "Name: oilpriceapi\n"
+ "Version: 9.9.9\n\n"
+ "https://api.oilpriceapi.com/product-facts.json\n"
+ )
+ (dist_info / "RECORD").write_text(
+ "oilpriceapi/version.py,,\n"
+ "oilpriceapi/py.typed,,\n"
+ "oilpriceapi/types.pyi,,\n"
+ "oilpriceapi/docs/catalog.json,,\n"
+ "oilpriceapi/__pycache__/version.cpython-312.pyc,,\n"
+ "oilpriceapi-9.9.9.dist-info/METADATA,,\n"
+ "oilpriceapi-9.9.9.dist-info/RECORD,,\n"
+ )
+
+ surfaces = {
+ path.relative_to(tmp_path).as_posix()
+ for path in discover_installed_surfaces(tmp_path)
+ }
+ failures = validate_package(tmp_path)
+
+ assert "oilpriceapi/types.pyi" in surfaces
+ assert "oilpriceapi/docs/catalog.json" in surfaces
+ assert not any("__pycache__" in surface for surface in surfaces)
+ assert any(
+ "oilpriceapi/types.pyi" in failure and "free-tier claim" in failure
+ for failure in failures
+ )
+ assert any(
+ "oilpriceapi/types.pyi" in failure and "unreviewed plan name" in failure
+ for failure in failures
+ )
+ assert any(
+ "oilpriceapi/types.pyi" in failure and "fixed allowance" in failure
+ for failure in failures
+ )
+ assert any("oilpriceapi/docs/catalog.json" in failure for failure in failures)