diff --git a/.github/workflows/github-pages.yml b/.github/workflows/github-pages.yml index 861baaa..2eda685 100644 --- a/.github/workflows/github-pages.yml +++ b/.github/workflows/github-pages.yml @@ -17,16 +17,20 @@ concurrency: jobs: deploy: + if: github.ref == format('refs/heads/{0}', github.event.repository.default_branch) environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} runs-on: ubuntu-latest + timeout-minutes: 15 steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false - name: Set up Python - uses: actions/setup-python@v7 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 with: python-version: "3.12" @@ -39,13 +43,13 @@ jobs: run: mkdocs build - name: Setup Pages - uses: actions/configure-pages@v6 + uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6 - name: Upload artifact - uses: actions/upload-pages-artifact@v5 + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5 with: path: "./site" - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v5 + uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5 diff --git a/.github/workflows/live-tests.yml b/.github/workflows/live-tests.yml index b696f8c..eb4e14e 100644 --- a/.github/workflows/live-tests.yml +++ b/.github/workflows/live-tests.yml @@ -6,8 +6,8 @@ name: Live API Tests # secret. Runs unconditionally on every push/PR, so route health and # envelope-shape coverage can never silently skip (this tier caught # the 442->436 catalog change keyless). -# 2. Keyed live tests — auth path + gated endpoints, only when the -# OILPRICEAPI_TEST_KEY secret is available (skips loudly on forks). +# 2. Keyed live tests — auth path + gated endpoints, required for exact +# default-branch code and never exposed to pull requests or other refs. on: push: @@ -16,16 +16,22 @@ on: branches: [main] workflow_dispatch: {} +permissions: + contents: read + jobs: live-tests: name: Live API tests runs-on: ubuntu-latest + timeout-minutes: 15 steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false - name: Set up Python - uses: actions/setup-python@v7 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 with: python-version: "3.12" @@ -38,24 +44,26 @@ jobs: - name: Keyless demo smoke (always runs) run: pytest tests/integration/test_demo_contract.py -m live --no-cov -v - # Tier 2: full live suite, gated on the repo secret (forks skip loudly). + # Tier 2: full live suite is required only for protected default-branch code. - name: Keyed live tests + if: github.ref == format('refs/heads/{0}', github.event.repository.default_branch) env: OILPRICEAPI_TEST_KEY: ${{ secrets.OILPRICEAPI_TEST_KEY }} run: | if [ -z "$OILPRICEAPI_TEST_KEY" ]; then - echo "::warning::OILPRICEAPI_TEST_KEY not available (fork?); keyed live tests skipped. Keyless demo smoke above still ran." - exit 0 + echo "::error::OILPRICEAPI_TEST_KEY is required for default-branch live tests" + exit 1 fi pytest tests/integration -m live --no-cov -v - name: Run canonical success snippets against production + if: github.ref == format('refs/heads/{0}', github.event.repository.default_branch) env: OILPRICEAPI_KEY: ${{ secrets.OILPRICEAPI_TEST_KEY }} run: | if [ -z "$OILPRICEAPI_KEY" ]; then - echo "::warning::OILPRICEAPI_TEST_KEY not available; canonical snippet smoke skipped." - exit 0 + echo "::error::OILPRICEAPI_TEST_KEY is required for default-branch snippet smoke" + exit 1 fi python examples/snippets/latest_price.py python examples/snippets/history.py diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c34cf20..d2e25ff 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -8,33 +8,52 @@ permissions: contents: read jobs: - test: - name: Run Tests Before Publish + verify: + name: Verify release candidate if: github.event.release.prerelease == false runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 + persist-credentials: false - name: Set up Python - uses: actions/setup-python@v7 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 with: python-version: "3.12" - - name: Install dependencies + - name: Install verification dependencies run: | python -m pip install --upgrade pip - pip install -e '.[dev]' pip-audit + python -m pip install -e '.[dev]' pip-audit build 'jsonschema>=4.17,<4.24' - - name: Verify release tag matches package version + - name: Verify release tag matches package version and protected main env: RELEASE_TAG: ${{ github.event.release.tag_name }} run: | - PACKAGE_VERSION="$(python -c 'from oilpriceapi.version import SDK_VERSION; print(SDK_VERSION)')" + set -euo pipefail + PACKAGE_VERSION="$(python scripts/package_version.py)" if [ "$RELEASE_TAG" != "v$PACKAGE_VERSION" ]; then echo "::error::Release tag $RELEASE_TAG does not match package version $PACKAGE_VERSION" exit 1 fi + git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main + RELEASE_COMMIT="$(git rev-parse "$RELEASE_TAG^{commit}")" + if [ "$RELEASE_COMMIT" != "$(git rev-parse HEAD)" ]; then + echo "::error::Checked-out commit does not match $RELEASE_TAG" + exit 1 + fi + if ! git merge-base --is-ancestor "$RELEASE_COMMIT" origin/main; then + echo "::error::$RELEASE_TAG is not reachable from protected main" + exit 1 + fi + - name: Lint source with ruff run: ruff check oilpriceapi/ @@ -44,45 +63,171 @@ jobs: - name: Audit installed dependencies run: pip-audit + - name: Validate public storefront claims + run: python scripts/validate_storefront_claims.py + + - name: Build package + run: python -m build + + - name: Install and import the exact built wheel + run: ./scripts/clean-wheel-smoke.sh + + - name: Build signed snippet manifest + run: | + python scripts/generate_snippet_manifest.py \ + --source-commit "$(git rev-parse HEAD)" \ + --output artifacts/snippets/oilpriceapi-python-snippets-v1.json + + - name: Prepare checksummed release artifact + run: | + set -euo pipefail + ARTIFACT_DIR="$RUNNER_TEMP/release-artifact" + mkdir -p "$ARTIFACT_DIR/dist" "$ARTIFACT_DIR/snippets" + cp dist/* "$ARTIFACT_DIR/dist/" + cp artifacts/snippets/* "$ARTIFACT_DIR/snippets/" + PACKAGE_VERSION="$(python scripts/package_version.py)" + printf 'PACKAGE_VERSION=%s\n' "$PACKAGE_VERSION" > "$ARTIFACT_DIR/release.env" + ( + cd "$ARTIFACT_DIR" + find dist snippets -type f -print0 \ + | sort -z \ + | xargs -0 sha256sum > artifact.sha256 + sha256sum release.env >> artifact.sha256 + ) + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: verified-pypi-package + path: ${{ runner.temp }}/release-artifact/ + if-no-files-found: error + retention-days: 1 + publish: - name: Publish to PyPI - needs: test + name: Publish verified package to PyPI + needs: verify if: github.event.release.prerelease == false runs-on: ubuntu-latest + timeout-minutes: 15 environment: pypi permissions: - id-token: write # Required for trusted publishing - contents: write + contents: read + id-token: write steps: - - uses: actions/checkout@v7 - - - name: Set up Python - uses: actions/setup-python@v7 + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: - python-version: "3.12" + name: verified-pypi-package + path: ${{ runner.temp }}/release-artifact - - name: Install build dependencies + - name: Verify exact artifact checksums + working-directory: ${{ runner.temp }}/release-artifact run: | - python -m pip install --upgrade pip - pip install build 'jsonschema>=4.17,<4.24' + set -euo pipefail + manifest_files="$RUNNER_TEMP/manifest-files" + actual_files="$RUNNER_TEMP/actual-files" + sed -n 's/^[0-9a-f]\{64\} //p' artifact.sha256 \ + | LC_ALL=C sort > "$manifest_files" + { + find dist snippets -type f -print + printf '%s\n' release.env + } | LC_ALL=C sort > "$actual_files" + if [ -n "$(find dist snippets -type l -print -quit)" ]; then + echo "::error::Verified release artifact contains a symlink" + exit 1 + fi + if ! cmp -s "$manifest_files" "$actual_files"; then + echo "::error::Checksum manifest does not cover the exact release files" + exit 1 + fi + sha256sum -c artifact.sha256 - - name: Build package - run: python -m build + - name: Publish exact verified distributions + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1 2026-07-28 + with: + packages-dir: ${{ runner.temp }}/release-artifact/dist/ + skip-existing: true - - name: Install and import the exact built wheel - run: ./scripts/clean-wheel-smoke.sh + readback: + name: Verify public PyPI artifact hashes + needs: publish + if: github.event.release.prerelease == false + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read - - name: Build signed snippet manifest + steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: verified-pypi-package + path: ${{ runner.temp }}/release-artifact + + - name: Verify exact public PyPI hashes + working-directory: ${{ runner.temp }}/release-artifact run: | - python scripts/generate_snippet_manifest.py \ - --source-commit "$GITHUB_SHA" \ - --output artifacts/snippets/oilpriceapi-python-snippets-v1.json + set -euo pipefail + sha256sum -c artifact.sha256 + PACKAGE_VERSION="$(sed -n 's/^PACKAGE_VERSION=//p' release.env)" + if ! printf '%s' "$PACKAGE_VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "::error::Invalid package version in verified artifact" + exit 1 + fi + + for attempt in $(seq 1 24); do + curl --fail --silent --show-error --max-time 10 \ + "https://pypi.org/pypi/oilpriceapi/$PACKAGE_VERSION/json" \ + > "$RUNNER_TEMP/pypi.json" || true + all_present=true + for file in dist/*; do + filename="$(basename "$file")" + expected="$(sha256sum "$file" | cut -d' ' -f1)" + actual="$(jq -r --arg filename "$filename" \ + '[.urls[]? | select(.filename == $filename) | .digests.sha256][0] // empty' \ + "$RUNNER_TEMP/pypi.json" 2>/dev/null || true)" + if [ -z "$actual" ]; then + all_present=false + elif [ "$actual" != "$expected" ]; then + echo "::error::PyPI $filename has an unexpected immutable hash" + exit 1 + fi + done + if [ "$all_present" = true ]; then + echo "Verified every public oilpriceapi $PACKAGE_VERSION distribution hash." + exit 0 + fi + if [ "$attempt" -lt 6 ]; then + sleep_seconds=$((attempt * 2)) + else + sleep_seconds=10 + fi + sleep "$sleep_seconds" + done + + echo "::error::PyPI public readback did not expose every verified distribution" + exit 1 + + release_assets: + name: Attach verified release assets + needs: readback + if: github.event.release.prerelease == false + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + + steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: verified-pypi-package + path: ${{ runner.temp }}/release-artifact - - name: Attach snippet manifest to release + - name: Attach checksummed snippet manifest + working-directory: ${{ runner.temp }}/release-artifact env: GH_TOKEN: ${{ github.token }} - run: gh release upload "${{ github.event.release.tag_name }}" artifacts/snippets/* --clobber - - - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 + GH_REPO: ${{ github.repository }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: | + set -euo pipefail + sha256sum -c artifact.sha256 + gh release upload "$RELEASE_TAG" snippets/* artifact.sha256 --clobber diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 46a2dda..f90020a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,20 +6,26 @@ on: pull_request: branches: [main] +permissions: + contents: read + jobs: test: name: Test (Python ${{ matrix.python-version }}) runs-on: ubuntu-latest + timeout-minutes: 20 strategy: fail-fast: false matrix: python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v7 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 with: python-version: ${{ matrix.python-version }} @@ -48,7 +54,7 @@ jobs: - name: Upload snippet manifest if: matrix.python-version == '3.12' - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: oilpriceapi-python-snippets-${{ github.sha }} path: artifacts/snippets/ @@ -56,7 +62,7 @@ jobs: - name: Upload coverage if: matrix.python-version == '3.12' - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: coverage-report path: coverage.xml diff --git a/.github/workflows/weekly-health.yml b/.github/workflows/weekly-health.yml index ac5c7e6..be9bb8e 100644 --- a/.github/workflows/weekly-health.yml +++ b/.github/workflows/weekly-health.yml @@ -15,16 +15,19 @@ concurrency: jobs: health-check: name: Latest + bounded history + if: github.ref == format('refs/heads/{0}', github.event.repository.default_branch) runs-on: ubuntu-latest timeout-minutes: 10 env: OILPRICEAPI_KEY: ${{ secrets.OILPRICEAPI_TEST_KEY }} steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false - name: Set up Python - uses: actions/setup-python@v7 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 with: python-version: "3.12" @@ -56,7 +59,7 @@ jobs: - name: Upload 30-day receipt if: always() && hashFiles('artifacts/sdk-health.json') != '' - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: sdk-health-${{ github.run_id }} path: artifacts/sdk-health.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 531bcc8..205ce1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.12.5] - 2026-08-11 + +### Added + +- Document a coverage-gated permit-to-production workflow and package discovery + keywords for well permits, drilling data, and well production. + +### Fixed + +- Accept live well-permit filters without a legacy free-form query and unwrap + the production `{ well_permits, meta }` search response in sync and async + clients while retaining positional-query compatibility. +- Require the PyPI publisher to verify the complete checksummed artifact set, + share one package-version parser, scan both workflow filename extensions, + and allow bounded public-index propagation before release completion. + ## [1.12.4] - 2026-08-11 ### Fixed diff --git a/README.md b/README.md index b149348..59e1d80 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,55 @@ print( Use the raw first-request pattern when downstream logic requires the exact source and timestamp-field semantics from the API response. +## Permit To Production + +Well-level production coverage is narrower than permit coverage. Check the +live coverage response before following a permit into monthly production: + +```python +import os + +from oilpriceapi import OilPriceAPI +from oilpriceapi.exceptions import DataNotFoundError + +with OilPriceAPI(api_key=os.environ["OILPRICEAPI_KEY"]) as client: + summary = client.well_production.summary() + if not isinstance(summary, dict): + raise RuntimeError("MALFORMED_RESPONSE: well-production summary is invalid") + coverage = summary.get("coverage") + if not isinstance(coverage, dict): + raise RuntimeError("MALFORMED_RESPONSE: well-production coverage is missing") + + covered_state_values = coverage.get("well_level_states_with_data") + if not isinstance(covered_state_values, list): + raise RuntimeError("MALFORMED_RESPONSE: well-level state coverage is missing") + covered_states = set(covered_state_values) + permits = client.ei.well_permits.search(states="TX", well_name="Eagle") + + for permit in permits: + api_number = permit.get("api_number") + if ( + permit.get("state_code") not in covered_states + or not isinstance(api_number, str) + or len(api_number) != 14 + or not api_number.isascii() + or not api_number.isdigit() + ): + continue + + try: + production = client.well_production.well(api_number) + except DataNotFoundError: + continue + well = permit.get("well") + well_name = well.get("name") if isinstance(well, dict) else None + print(well_name, production.get("data", [])) +``` + +An empty permit search or production history is a valid data state. Do not +infer broader well-level coverage from the presence of permit data or an SDK +helper; dataset and account availability come from the current API response. + ## Complete pandas DataFrames Install the optional pandas support, then request a historical DataFrame: diff --git a/oilpriceapi/async_resources.py b/oilpriceapi/async_resources.py index 96926c1..03ec65a 100644 --- a/oilpriceapi/async_resources.py +++ b/oilpriceapi/async_resources.py @@ -18,6 +18,7 @@ search_commodity_catalog, ) from .resources._futures_slug import normalize_futures_slug +from .resources.ei.well_permits import unwrap_well_permit_search_response from .resources.subscriptions import SubscriptionEventsPage @@ -1246,14 +1247,17 @@ async def by_formation(self, **params) -> List[Dict[str, Any]]: return response["data"] return response - async def search(self, query: str, **params) -> List[Dict[str, Any]]: - params["query"] = query + async def search( + self, + query: Optional[str] = None, + **params: Any, + ) -> List[Dict[str, Any]]: + if query is not None: + params["query"] = query response = await self.client.request( method="GET", path="/v1/ei/well-permits/search", params=params ) - if "data" in response: - return response["data"] - return response + return unwrap_well_permit_search_response(response) class AsyncEIFracFocusResource: diff --git a/oilpriceapi/resources/ei/well_permits.py b/oilpriceapi/resources/ei/well_permits.py index fcfc908..a00600d 100644 --- a/oilpriceapi/resources/ei/well_permits.py +++ b/oilpriceapi/resources/ei/well_permits.py @@ -4,7 +4,33 @@ Energy Intelligence well permit data operations. """ -from typing import Any, Dict, List +from typing import Any, Dict, List, Optional + +from ...exceptions import OilPriceAPIError + + +def unwrap_well_permit_search_response(response: Any) -> List[Dict[str, Any]]: + """Return a typed permit list or fail on an unknown successful shape.""" + permits: Any + if isinstance(response, list): + permits = response + elif isinstance(response, dict) and "well_permits" in response: + permits = response["well_permits"] + elif isinstance(response, dict) and isinstance(response.get("data"), dict): + data = response["data"] + permits = data.get("well_permits") if "well_permits" in data else None + elif isinstance(response, dict) and "data" in response: + permits = response["data"] + else: + permits = None + + if not isinstance(permits, list) or not all(isinstance(item, dict) for item in permits): + raise OilPriceAPIError( + "Malformed well-permit search response: expected a well_permits list", + code="MALFORMED_RESPONSE", + raw_body=response, + ) + return permits class EIWellPermitsResource: @@ -181,29 +207,32 @@ def by_formation(self, **params) -> List[Dict[str, Any]]: return response["data"] return response - def search(self, query: str, **params) -> List[Dict[str, Any]]: + def search( + self, + query: Optional[str] = None, + **params: Any, + ) -> List[Dict[str, Any]]: """Search well permits. Args: - query: Search query string - **params: Optional query parameters for filtering + query: Optional legacy free-form query string. + **params: Live search filters such as ``states``, ``county``, + ``well_name``, ``permit_type``, and date or radius fields. Returns: List of matching permit records Example: - >>> results = client.ei.well_permits.search("Chevron") + >>> results = client.ei.well_permits.search(states="TX", well_name="Eagle") >>> for result in results: - ... print(f"{result['operator']}: {result['state']}") + ... print(f"{result['operator']['name']}: {result['state_code']}") """ - params["query"] = query + if query is not None: + params["query"] = query response = self.client.request( method="GET", path="/v1/ei/well-permits/search", params=params ) - # Parse response - if "data" in response: - return response["data"] - return response + return unwrap_well_permit_search_response(response) diff --git a/oilpriceapi/version.py b/oilpriceapi/version.py index de4188b..ad90a92 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.4" +__version__ = "1.12.5" SDK_VERSION = __version__ SDK_NAME = "oilpriceapi-python" diff --git a/pyproject.toml b/pyproject.toml index 93c40dd..50906ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta" [project] name = "oilpriceapi" -version = "1.12.4" +version = "1.12.5" description = "Official Python SDK for source-timestamped OilPriceAPI energy data" authors = [ {name = "OilPriceAPI", email = "support@oilpriceapi.com"} @@ -18,7 +18,8 @@ readme = "README.md" license = {file = "LICENSE"} keywords = [ "oil", "prices", "api", "commodities", "energy", - "brent", "wti", "natural gas", "source timestamps", "finance" + "brent", "wti", "natural gas", "source timestamps", "finance", + "well permits", "drilling data", "oil well production" ] classifiers = [ "Development Status :: 5 - Production/Stable", diff --git a/scripts/package_version.py b/scripts/package_version.py new file mode 100644 index 0000000..ecda748 --- /dev/null +++ b/scripts/package_version.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python3 +"""Print the canonical package version from pyproject.toml.""" + +import re +from pathlib import Path + + +def package_version(project: Path) -> str: + match = re.search( + r'^version = "([^"]+)"$', + project.read_text(encoding="utf-8"), + re.MULTILINE, + ) + if match is None: + raise SystemExit("package version not found") + return match.group(1) + + +if __name__ == "__main__": + print(package_version(Path(__file__).resolve().parents[1] / "pyproject.toml")) diff --git a/tests/test_release_readiness.py b/tests/test_release_readiness.py index eff5f4c..1d1f893 100644 --- a/tests/test_release_readiness.py +++ b/tests/test_release_readiness.py @@ -1,7 +1,65 @@ import re +import subprocess +import sys from pathlib import Path +from typing import List ROOT = Path(__file__).resolve().parents[1] +WORKFLOW_DIR = ROOT / ".github" / "workflows" +DEFAULT_BRANCH_IF = ( + "if: github.ref == format('refs/heads/{0}', " + "github.event.repository.default_branch)" +) + + +def _checkout_step_blocks(workflow: str) -> List[str]: + lines = workflow.splitlines() + blocks: List[str] = [] + + for index, line in enumerate(lines): + match = re.match( + r"^(\s*)(?:-\s+)?uses:\s*actions/checkout@[0-9a-f]{40}(?:\s*#.*)?$", + line, + ) + if match is None: + continue + uses_indent = len(match.group(1)) + indent = uses_indent if line.lstrip().startswith("- ") else uses_indent - 2 + start = index + while start >= 0 and not re.match(rf"^\s{{{indent}}}-\s+", lines[start]): + start -= 1 + assert start >= 0, f"checkout action at line {index + 1} is outside a step" + + end = start + 1 + while end < len(lines) and not re.match(rf"^\s{{{indent}}}-\s+", lines[end]): + end += 1 + blocks.append("\n".join(lines[start:end])) + + return blocks + + +def _checkout_step_is_hardened(block: str) -> bool: + lines = block.splitlines() + step = re.match(r"^(\s*)-\s+", lines[0]) + assert step is not None + step_indent = len(step.group(1)) + with_indent = step_indent + 2 + value_indent = with_indent + 2 + inside_with = False + + for line in lines[1:]: + if re.match(rf"^\s{{{with_indent}}}with:\s*(?:#.*)?$", line): + inside_with = True + continue + if inside_with and re.match(rf"^\s{{{with_indent}}}\S", line): + inside_with = False + if inside_with and re.match( + rf"^\s{{{value_indent}}}persist-credentials:\s*false\s*(?:#.*)?$", + line, + ): + return True + + return False def test_release_documentation_matches_the_automated_gate() -> None: @@ -42,6 +100,97 @@ def test_publish_gate_audits_and_installs_the_built_wheel() -> None: assert "-name '*.whl' -print -quit" not in smoke +def test_oidc_publisher_consumes_only_the_verified_artifact() -> None: + workflow = (ROOT / ".github" / "workflows" / "publish.yml").read_text() + jobs = re.split(r"(?m)(?=^ [a-z][a-z0-9_-]*:\n)", workflow) + publish = next(section for section in jobs if section.startswith(" publish:\n")) + action_refs = re.findall(r"uses:\s+[^@\s]+@([^\s#]+)", workflow) + + assert "Verify release tag matches package version and protected main" in workflow + assert "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a" in workflow + assert "actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c" in workflow + assert "pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33" in publish + assert "id-token: write" in publish + assert "sha256sum -c artifact.sha256" in publish + assert "cmp -s" in publish + assert "find dist snippets -type l" in publish + for forbidden in ( + "actions/checkout@", + "actions/setup-python@", + "pip install", + "python -m", + "pytest", + "scripts/", + ): + assert forbidden not in publish + assert action_refs + assert all(re.fullmatch(r"[0-9a-f]{40}", ref) for ref in action_refs) + assert "Verify exact public PyPI hashes" in workflow + assert workflow.count("python scripts/package_version.py") == 2 + assert "seq 1 24" in workflow + assert "sleep_seconds" in workflow + + +def test_package_version_helper_reads_the_project_version() -> None: + helper = ROOT / "scripts" / "package_version.py" + + assert helper.is_file() + result = subprocess.run( + [sys.executable, str(helper)], + cwd=ROOT, + check=True, + capture_output=True, + text=True, + ) + assert result.stdout.strip() == "1.12.5" + + +def test_every_workflow_pins_actions_and_hardens_each_checkout_step() -> None: + workflows = sorted({*WORKFLOW_DIR.glob("*.yml"), *WORKFLOW_DIR.glob("*.yaml")}) + + assert workflows + for path in workflows: + workflow = path.read_text() + action_refs = re.findall(r"uses:\s+[^@\s]+@([^\s#]+)", workflow) + assert action_refs, f"{path.name} contains no action reference" + assert all(re.fullmatch(r"[0-9a-f]{40}", ref) for ref in action_refs), path.name + + checkout_blocks = _checkout_step_blocks(workflow) + if "actions/checkout@" in workflow: + assert checkout_blocks, f"{path.name} checkout is not pinned" + for block in checkout_blocks: + assert _checkout_step_is_hardened(block), ( + f"{path.name} checkout retains credentials:\n{block}" + ) + + +def test_checkout_hardening_cannot_be_borrowed_from_an_env_mapping() -> None: + workflow = """steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + fetch-depth: 0 + env: + persist-credentials: false +""" + blocks = _checkout_step_blocks(workflow) + + assert len(blocks) == 1 + assert not _checkout_step_is_hardened(blocks[0]) + + +def test_secret_and_identity_workflows_only_run_default_branch_code() -> None: + live = (WORKFLOW_DIR / "live-tests.yml").read_text() + weekly = (WORKFLOW_DIR / "weekly-health.yml").read_text() + pages = (WORKFLOW_DIR / "github-pages.yml").read_text() + + assert live.count(DEFAULT_BRANCH_IF) == 2 + assert "OILPRICEAPI_TEST_KEY is required" in live + assert "exit 0" not in live + assert DEFAULT_BRANCH_IF in weekly + assert DEFAULT_BRANCH_IF in pages + + def test_packaging_configuration_remains_compatible_with_supported_python() -> None: project = (ROOT / "pyproject.toml").read_text() @@ -58,3 +207,24 @@ def test_manifest_has_no_noop_exclusion_patterns() -> None: for stale in ("global-exclude", "exclude test_sdk_live.py", "prune "): assert stale not in manifest + + +def test_readme_documents_coverage_gated_permit_to_production() -> None: + readme = (ROOT / "README.md").read_text() + project = (ROOT / "pyproject.toml").read_text() + + for required in ( + '"well_level_states_with_data"', + "client.ei.well_permits.search", + "client.well_production.well", + "except DataNotFoundError:", + "if not isinstance(summary, dict):", + "api_number.isascii()", + "api_number.isdigit()", + "len(api_number) != 14", + "An empty permit search or production history is a valid data state.", + ): + assert required in readme + + for keyword in ('"well permits"', '"drilling data"', '"oil well production"'): + assert keyword in project diff --git a/tests/unit/test_ei_well_permits_resource.py b/tests/unit/test_ei_well_permits_resource.py new file mode 100644 index 0000000..922e534 --- /dev/null +++ b/tests/unit/test_ei_well_permits_resource.py @@ -0,0 +1,122 @@ +from unittest.mock import AsyncMock, Mock + +import pytest + +from oilpriceapi.async_resources import AsyncEIWellPermitsResource +from oilpriceapi.exceptions import OilPriceAPIError +from oilpriceapi.resources.ei.well_permits import EIWellPermitsResource + +PERMIT = { + "api_number": "42329000000001", + "state_code": "TX", + "well": {"name": "Eagle 1"}, +} + + +@pytest.mark.parametrize( + "response", + [ + [PERMIT], + {"data": [PERMIT]}, + {"well_permits": [PERMIT], "meta": {"count": 1}}, + {"status": "success", "data": {"well_permits": [PERMIT], "meta": {"count": 1}}}, + ], +) +def test_search_accepts_live_filters_and_unwraps_supported_shapes(response) -> None: + client = Mock() + client.request.return_value = response + resource = EIWellPermitsResource(client) + + permits = resource.search(states="TX", well_name="Eagle") + + assert permits == [PERMIT] + client.request.assert_called_once_with( + method="GET", + path="/v1/ei/well-permits/search", + params={"states": "TX", "well_name": "Eagle"}, + ) + + +def test_search_keeps_legacy_query_parameter() -> None: + client = Mock() + client.request.return_value = {"data": [PERMIT]} + resource = EIWellPermitsResource(client) + + assert resource.search("Eagle", states="TX") == [PERMIT] + assert client.request.call_args.kwargs["params"] == {"states": "TX", "query": "Eagle"} + + +@pytest.mark.parametrize( + "response", + [ + {"status": "success", "data": {"items": []}}, + {"status": "success"}, + "unexpected response", + ], +) +def test_search_rejects_unknown_success_shapes(response) -> None: + client = Mock() + client.request.return_value = response + resource = EIWellPermitsResource(client) + + with pytest.raises(OilPriceAPIError, match="well_permits") as error: + resource.search(states="TX") + + assert error.value.code == "MALFORMED_RESPONSE" + + +def test_search_accepts_an_explicit_empty_result() -> None: + client = Mock() + client.request.return_value = { + "status": "success", + "data": {"well_permits": [], "meta": {"count": 0}}, + } + + assert EIWellPermitsResource(client).search(states="TX") == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "response", + [ + [PERMIT], + {"data": [PERMIT]}, + {"well_permits": [PERMIT], "meta": {"count": 1}}, + {"status": "success", "data": {"well_permits": [PERMIT], "meta": {"count": 1}}}, + ], +) +async def test_async_search_accepts_live_filters_and_unwraps_supported_shapes(response) -> None: + client = Mock() + client.request = AsyncMock(return_value=response) + resource = AsyncEIWellPermitsResource(client) + + permits = await resource.search(states="TX", well_name="Eagle") + + assert permits == [PERMIT] + client.request.assert_awaited_once_with( + method="GET", + path="/v1/ei/well-permits/search", + params={"states": "TX", "well_name": "Eagle"}, + ) + + +@pytest.mark.asyncio +async def test_async_search_keeps_legacy_query_parameter() -> None: + client = Mock() + client.request = AsyncMock(return_value={"data": [PERMIT]}) + resource = AsyncEIWellPermitsResource(client) + + assert await resource.search("Eagle", states="TX") == [PERMIT] + assert client.request.call_args.kwargs["params"] == {"states": "TX", "query": "Eagle"} + + +@pytest.mark.asyncio +async def test_async_search_rejects_unknown_success_shape() -> None: + client = Mock() + client.request = AsyncMock(return_value={"status": "success", "data": {"items": []}}) + resource = AsyncEIWellPermitsResource(client) + + with pytest.raises(OilPriceAPIError, match="well_permits") as error: + await resource.search(states="TX") + + assert error.value.code == "MALFORMED_RESPONSE"