diff --git a/CHANGELOG.md b/CHANGELOG.md index e594edf..a09904c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.12.2] - 2026-08-11 + +### Fixed + +- Remove stale fixed plan-price, monthly allowance, cadence, uptime, and + generic real-time claims from documentation and packaged docstrings. +- Recursively validate authored docs and package source, then scan the exact + installed wheel and PyPI metadata during the release smoke test. + ## [1.12.1] - 2026-08-11 ### Fixed diff --git a/EXAMPLES.md b/EXAMPLES.md index ca30a47..cd5f76e 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -2,7 +2,7 @@ This guide showcases practical applications of the [OilPriceAPI Python SDK](https://oilpriceapi.com) for energy trading, financial analysis, research, and application development. -**[Get your free API key →](https://oilpriceapi.com/auth/signup)** to run these examples. +**[Create an API key →](https://oilpriceapi.com/auth/signup)** to run these examples. ## 📊 Table of Contents @@ -262,7 +262,7 @@ print(f"Predicted change: {((predictions[-1] - y[-1]) / y[-1] * 100):.2f}%") ## 💻 Web & Mobile Applications -### Example 7: Real-Time Price Dashboard (Streamlit) +### Example 7: Current Price Dashboard (Streamlit) Create an interactive web dashboard for monitoring oil prices. @@ -320,7 +320,7 @@ try: ) st.plotly_chart(fig, use_container_width=True) - st.success(f"✅ Data updates every 5 minutes • [View all commodities](https://docs.oilpriceapi.com/commodities)") + st.success(f"✅ Values include API-provided source timestamps • [View commodity metadata](https://docs.oilpriceapi.com/commodities)") except Exception as e: st.error(f"Error: {e}") @@ -456,7 +456,7 @@ def monitor_prices(): elif price.value < limits['low']: send_alert(commodity, price.value, limits['low'], 'BELOW') - # Check every 5 minutes (aligned with API update frequency) + # Example caller-selected interval; honor API limit and freshness metadata. time.sleep(300) if __name__ == '__main__': @@ -621,7 +621,7 @@ print("📊 Powered by https://oilpriceapi.com") Ready to build with these examples? -1. **[Sign up for free](https://oilpriceapi.com/auth/signup)** - Get 50 requests/day +1. **[Create an API key](https://oilpriceapi.com/auth/signup)** - The current free-account allowance is 50 requests/day; verify the [product facts](https://api.oilpriceapi.com/product-facts.json) 2. **[Install the SDK](https://pypi.org/project/oilpriceapi/)** - `pip install oilpriceapi` 3. **[Read the docs](https://docs.oilpriceapi.com/sdk/python)** - Complete API reference 4. **[Choose a plan](https://oilpriceapi.com/pricing)** - Upgrade for more requests diff --git a/docs/PERFORMANCE_GUIDE.md b/docs/PERFORMANCE_GUIDE.md index 8304a92..421446e 100644 --- a/docs/PERFORMANCE_GUIDE.md +++ b/docs/PERFORMANCE_GUIDE.md @@ -231,23 +231,23 @@ while True: **Problems:** - Wastes API quota - Unnecessary load on API -- Price only updates ~every 5 minutes +- Ignores the record's API-provided source timestamp and freshness metadata **Solution:** ```python -# Poll at reasonable interval +# Choose an interval from API limits and the application's freshness need import time while True: price = client.prices.get("WTI_USD") print(f"WTI: ${price.value}") - time.sleep(300) # Poll every 5 minutes + time.sleep(300) # Example client-selected interval ``` -**Better Solution (for real-time):** +**Better Solution (for streamed updates):** ```python -# Use WebSocket for real-time updates (if available) -# Or increase polling interval to match update frequency +# Use WebSocket streaming when the account is entitled to it. +# Otherwise use response metadata to select the polling interval. ``` ### Pitfall 2: Fetching All Historical Data @@ -335,24 +335,30 @@ price = client.prices.get("WTI_USD") # Resilient **Basic In-Memory Cache:** ```python -from datetime import datetime, timedelta -from functools import lru_cache +from datetime import datetime, timedelta, timezone -@lru_cache(maxsize=100) -def get_cached_price(commodity, cache_key): - """Cache prices for 5 minutes.""" - client = OilPriceAPI() - return client.prices.get(commodity) +price_cache = {} +# Illustrative application policy; choose this for your freshness requirement. +MAX_SOURCE_AGE = timedelta(minutes=5) -# Cache key changes every 5 minutes def get_current_price(commodity): - cache_key = int(datetime.now().timestamp() / 300) - return get_cached_price(commodity, cache_key) + cached = price_cache.get(commodity) + if cached: + source_age = datetime.now(timezone.utc) - cached["source_timestamp"] + if source_age <= MAX_SOURCE_AGE: + return cached["price"] + + price = client.prices.get(commodity) + price_cache[commodity] = { + "price": price, + "source_timestamp": price.timestamp, + } + return price # First call: API request (150ms) price1 = get_current_price("WTI_USD") -# Second call within 5 min: cached (<1ms) +# A second call is cached only while its source timestamp meets the policy. price2 = get_current_price("WTI_USD") ``` @@ -360,27 +366,33 @@ price2 = get_current_price("WTI_USD") ```python import redis import json -from datetime import timedelta +from datetime import datetime, timedelta, timezone + +from oilpriceapi.models import Price redis_client = redis.Redis(host='localhost', port=6379) +# Illustrative application policy; use your required maximum source age. +MAX_SOURCE_AGE = timedelta(minutes=5) def get_cached_price(client, commodity): - """Cache price in Redis for 5 minutes.""" - cache_key = f"oilprice:{commodity}" + cache_key = f"oilprice:{commodity}:latest" - # Check cache cached = redis_client.get(cache_key) if cached: - return json.loads(cached) + payload = json.loads(cached) + source_timestamp = datetime.fromisoformat(payload["source_timestamp"]) + if datetime.now(timezone.utc) - source_timestamp <= MAX_SOURCE_AGE: + return Price.model_validate(payload["price"]) - # Fetch from API price = client.prices.get(commodity) - - # Cache for 5 minutes + payload = { + "price": price.model_dump(mode="json"), + "source_timestamp": price.timestamp.isoformat(), + } redis_client.setex( cache_key, - timedelta(minutes=5), - json.dumps(price.dict()) + int(MAX_SOURCE_AGE.total_seconds()), + json.dumps(payload), ) return price @@ -389,13 +401,13 @@ def get_cached_price(client, commodity): ### When to Cache ✅ **Good candidates for caching:** -- Latest prices (updates every 5 minutes) +- Latest prices, keyed by the API-provided source timestamp - Historical data (never changes) - Commodity metadata - Static reference data ❌ **Don't cache:** -- Real-time price updates (if using WebSocket) +- Streamed price updates (when using WebSocket) - User-specific data - Data that changes frequently diff --git a/docs/TELEMETRY.md b/docs/TELEMETRY.md index 5a02cfb..836cb29 100644 --- a/docs/TELEMETRY.md +++ b/docs/TELEMETRY.md @@ -188,10 +188,10 @@ Possible causes: v ┌─────────────┐ │ Telemetry │ -│ Buffer │ 2. Buffer events (max 10 or 5min) +│ Buffer │ 2. Buffer events (max 10 or configured batch interval) └──────┬──────┘ │ - │ 3. Flush batch every 5 minutes + │ 3. Flush on the configured batch interval │ v ┌─────────────────────┐ diff --git a/docs/index.html b/docs/index.html index b08e951..f3486f7 100644 --- a/docs/index.html +++ b/docs/index.html @@ -3,13 +3,13 @@ - OilPriceAPI Python SDK - Real-time Oil & Commodity Price Data - + OilPriceAPI Python SDK - Source-Timestamped Commodity Data + - + @@ -209,12 +209,12 @@

🛢️ OilPriceAPI Python SDK

-

Real-time oil & commodity price data for Python developers

-

Professional-grade API at 98% less cost than Bloomberg Terminal

+

Source-timestamped oil and commodity data for Python developers

+

Typed API access with explicit currency, unit, source, and timestamp context

Install from PyPI - Get Free API Key + Create API Key
pip install oilpriceapi
@@ -239,8 +239,8 @@

🛢️ OilPriceAPI Python SDK

-

⚡ 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)