mirror of
https://github.com/OpenBB-finance/OpenBB.git
synced 2026-10-29 13:32:08 +00:00
* [Feature] OpenBB V5: Initial `openbb-core` Refactor (#7482)
* initial refactor
* ruff does not dictate style
* unresolved imports in test action
* fix general linting
* resolve pandas imports for actions run
* httpx in test workflow
* pytest-asyncio in test workflow
* pandas.Index(...)
* max version for windows CI is 3.14
* remove temp refactor files that shouldn't have been committed:
* use ruff format instead of black
* formatting and typing
* tab space helper
* test fixtures..
* ty check
* also run integration test in CI
* remove that annoying labeler action
* [V5] `openbb-core`: 100% Test Coverage (#7484)
* increase test coverage to exercise 100% of the testable code path in openbb-core
* model_fields syntax cleanup
* set the environment variable for CI runs
* run codeql on v5 branch
* more CI env setup
* cleanup redundant files
* ruff
* dedupe workflow trigger
* some windows coverage missing
* ubuntu integration test job
* more windows coverage
* test_reference_generator
* test_docstring_generator
* remove line no references from test function docstrings
* clean useless comments from test files
* fix polars type check
* fix test_obbject
* fix test_obbject
* package builder fixes
* charting pyproject version
* fix docstring generator for nested models
* missing coverage
* [V5] `openbb-core`: Add Backwards-Compatible Layered Config System (openbb.toml) (#7492)
* add backwards-compatible layered config system
* fix path reference in windows test
* need python version-aware handling in docstring generator
* update for deprecated pydantic syntax
* ruff UP007
* test_system_settings
* remove v4 platform workflow
* [V5] Refactor `openbb-oecd` for full SDMX 2 API Exposure and V5 Packaging (#7413)
* stash some changes
* add more robust testing
* mypy
* point PR at V5
* introduce spec file
* codespell
* test fix
* fix workflow environment setup
* fix workflow environment setup
* fix workflow environment setup
* add pyyaml to dependencies
* split lint jobs
* fix workflow environment setup
* fix workflow environment setup
* workflow env setup
* workflow env setup
* clean up code comments
* add auth hook entrypoints
* codespell
* add codegen feature
* codespell
* move _unpack into dispatchers for consistency with codegen packages
* surface nested models in the response
* fix missing coverage in CI
* socrata updates
* test fix
* detect plotly output
* add --include and --exclude flags from generate-extension command
* refactor openbb-platform-api for V5 operations and spec file handling
* delete poetry.lock
* add test workflow
* add temp branch id for test workflow
* add httpx to dev dependencies
* add a conftest.py
* tweak conftest
* tweak conftest
* python 3.10 issues
* more python 3.10
* more python 3.10
* more python 3.10
* more python 3.10
* fix url template resolution
* multi-spec support
* fix a windows path issue
* merge branch develop
* table builder, integration tests, and more
* linting
* merge branch develop
* bit of cleanup
* some more updates, cleanup, and apps.json
* codespell
* black
* integration test params and linting
* mypy
* pylint
* black
* more pylint
* recapture teest cassettes
* black again
* more linting
* table presentation choices
* black
* fix merge conflict
* split up metadata module and reformat cache to use msgpack
* mypy
* regenerate lock file
* use json and include dependencies to make openbb-oecd a standalone install
* pylint
* more linting
* serve metadata as dependency instead of assign and initialize
* fix integration_tests_testers to not count dependency injections as paramters
* add assets to git ignore
* ruff
* v5 refactor and publish workflow
* test coverage and ty fixes
* codespell
* update lock file
* workflow setup
* ty check
* workflow setup
* Scope PR to OECD changes only
Restore non-OECD files in the diff against v5 to their v5 state. The
branch was originally created off feature/cli-2 and inherited 186 files
of unrelated changes (cli, agent_server, mcp_server, platform_api,
core/, .github/workflows/* not oecd). Those have nothing to do with the
openbb-oecd v5 refactor and were polluting the PR diff.
* scope cleanup: include leaked v5-resident files (general-linting workflow, platform_api test fixtures)
---------
Co-authored-by: deeleeramone <>
Co-authored-by: Copilot <copilot@github.com>
* fix docstring generator and body param unwrapping
* project ty.toml file
* unpack POST parameters from params: Model
* remove comment chatter
* [V5] `openbb-oecd`: Rename `oecd_utils` as `oecd` (#7509)
* rename oecd_utils to oecd and populate all models there when openbb-economy is not installed
* linting
* bump dependencies
* fix test drift
* linter
* FieldInfo import
* [V5] `openbb-imf`: Refactor for V5 (#7508)
* refactor imf extension for V5
* codespell
* fix some python 3.10 items
* return self on copy of metadata object
* [V5] `openbb-news`: Refactor News for V5 and Add Configurable RSS Endpoint (#7507)
* refactor news for v5 and add rss endpoint
* remove poetry.lock
---------
Co-authored-by: deeleeramone <>
* [V5] `openbb-quantitative`: Refactor for V5 (#7506)
* refactor quantitative for v5
* add factors endpoint and add a few charts
---------
Co-authored-by: deeleeramone <>
* [V5] `openbb-technical`: Refactor for V5 & Add More Quick Win Endpoints (#7502)
* refactor openbb-technical for v5
* forward refs
* linting
* no python 3.15 in test matrix
* reame
* ty
* move all params to body
---------
Co-authored-by: deeleeramone <>
* remove stockgrid provider (#7486)
* [V5] remove wsj provider and related code (#7485)
* remove wsj provider and related code
* purge from metadata
* actions
* actions
* openbb-core publish workflow
* delete biztoc (#7523)
* [V5] Remove `openbb-alpha-vantage` (#7522)
* delete alpha vantage
* more files
---------
Co-authored-by: Theodore Aptekarev <aptekarev@gmail.com>
* [V5] Remove `openbb-benzinga` (#7521)
* delete benzinga
* more references
---------
Co-authored-by: Theodore Aptekarev <aptekarev@gmail.com>
* [V5] `openbb-bls`: Refactor for V5 + Add Many Endpoints (#7518)
* refactor bls for v5, add 100 widgets
* some CI fixes
* no python 3.15
* fix missing dev group in pyproject.toml
* more pyproject fallout
* action
* refactor econometrics for v5 (#7505)
Co-authored-by: deeleeramone <>
* [V5] `openbb-famafrench`: Refactor for V5 (#7503)
* refactor famafrench for v5
* codespell
* finishing touches
---------
Co-authored-by: deeleeramone <>
* [V5] CLI 2.0 - Pluggable Backends, Non-TTY Default, HTTP Dispatcher, Spec Files + Codegen (#7433)
* stash some changes
* add more robust testing
* mypy
* point PR at V5
* introduce spec file
* codespell
* test fix
* fix workflow environment setup
* fix workflow environment setup
* fix workflow environment setup
* add pyyaml to dependencies
* split lint jobs
* fix workflow environment setup
* fix workflow environment setup
* workflow env setup
* workflow env setup
* clean up code comments
* add auth hook entrypoints
* codespell
* add codegen feature
* codespell
* move _unpack into dispatchers for consistency with codegen packages
* surface nested models in the response
* fix missing coverage in CI
* socrata updates
* test fix
* detect plotly output
* add --include and --exclude flags from generate-extension command
* cap test matrix at python 3.14
* no useless comments
* platform controller command description split
* merge URL overloads from path params
* exclude none and unset from model dump
---------
Co-authored-by: deeleeramone <>
Co-authored-by: Copilot <copilot@github.com>
* general-linting
* downgrade aiohttp in devtools for vcrpy
* some ty items and add requests-types stubs for ty in devtools
* markdown linting
* remove integration tests from extensions that were moved into the famafrench provider
* [V5] `openbb-congress-gov`: Refactor for V5 + Convert To Using Bulk Files Instead of `api.congress.gov`. (#7528)
* refactor congress-gov for v5 and use bulk files instead of API with credentials
* integration tests
* apps tab name
* convert caching to sql
* codespell
* [V5] [BugFix] Fix for Breaking Changes in FastAPI 0.137+ (#7537)
* fix route introspection for fastapi 0.137
* recapture oeccd gdp forecast test cassette
---------
Co-authored-by: deeleeramone <>
* [V5] `openbb-platform-api`: Refactor for V5 Spec, Config, Packaging, Test Coverage (#7494)
* stash some changes
* add more robust testing
* mypy
* point PR at V5
* introduce spec file
* codespell
* test fix
* fix workflow environment setup
* fix workflow environment setup
* fix workflow environment setup
* add pyyaml to dependencies
* split lint jobs
* fix workflow environment setup
* fix workflow environment setup
* workflow env setup
* workflow env setup
* clean up code comments
* add auth hook entrypoints
* codespell
* add codegen feature
* codespell
* move _unpack into dispatchers for consistency with codegen packages
* surface nested models in the response
* fix missing coverage in CI
* socrata updates
* test fix
* detect plotly output
* add --include and --exclude flags from generate-extension command
* refactor openbb-platform-api for V5 operations and spec file handling
* delete poetry.lock
* add test workflow
* add temp branch id for test workflow
* add httpx to dev dependencies
* add a conftest.py
* tweak conftest
* tweak conftest
* python 3.10 issues
* more python 3.10
* more python 3.10
* more python 3.10
* more python 3.10
* fix url template resolution
* multi-spec support
* fix a windows path issue
* cap test matrix at python 3.14
* no 3.15 runners for platform-api
* no useless comments
* platform controller command description split
* merge URL overloads from path params
* exclude none and unset from model dump
* markdown lint
* ignore get/post endpoints that are return SSE
* fix remaining fastapi 0.137 fallout
---------
Co-authored-by: deeleeramone <>
Co-authored-by: Copilot <copilot@github.com>
* add full support for AsyncIterator response type for streaming output (#7530)
* [V5] Refactor Shared Pass Through Router Extensions For Hatchling (#7524)
* refactor shared pass-through routers for hatchling and ruff + ty
* add publish workflows
* [V5] `openbb-charting`: Refactor for V5 and Decouple (#7545)
* refactor charting for v5 patterns and decouple entirely
* coveragerc
* lint and tests
* fix tests
* configure dev group with openbb-technical
* ty
* fix cli test
* remove 3.15 from test matrix - dependencies are unstable
---------
Co-authored-by: deeleeramone <>
* fix merge widgets
* workflow cleanup"
* fixes for ty
* fogot that file
* general lint setup
* consolidate lint job
* remove seeking alpha provider (#7570)
* remove tradier provider (#7573)
* remove tiingo provider (#7574)
* remove intrinio provider (#7575)
Remove the openbb-intrinio provider extension from the repository.
Reason: proprietary vendor with no internal access to any endpoint;
cannot build, develop or maintain.
* remove yfinance provider from repo (#7576)
* fix(cli): resolve modular OpenAPI references (#7586)
Fixes #7585
* [V5] `openbb-cli`: Modular OpenAPI Refs (#7589)
* follow up to #7585
* add another codegen test
* unused ty ignore
* Update generated tests to get python parameter names instead of raw API-provider parameter names. (#7597)
* Update the record-vs-envelope heuristic in the payload processing code to not treat a record that happens to have a list datatype as an entire envelope. (#7598)
* Update class generation code to handle empty data that php-style serialization emits as []. (#7599)
* fix ty check failures exposed by newer version
* add drugs.com and brutalist as news rss feeds (#7602)
Co-authored-by: deeleeramone <>
* codespell
* missing test coverage that appeared in famafrench..
* plotly 7 handling for mapbox namespace change
* remove finviz (#7577)
* remove trading economics provider (#7578)
* remove openbb-multpl from repository (#7610)
* add httpx2 to devtools for starlette test client
* restore test coverage to 100% in openbb_core
* remove fmp provider extension from repository (#7665)
* unpin ruff
* fix charting conftest and missing coverage in 3.10
* add charting coverage
* fix new ty issues
* fix tag grouping in swagger docs
* [V5] `openbb-ecb`: Refactor for V5 and Expand Coverage (#7560)
* refactor openbb-ecb for v5 and expand coverage
* some updates
* tests
* workflow
* remove 3.15 from test matrix
* some more table schema updates
* remove some html chrome on embedded articles
---------
Co-authored-by: deeleeramone <>
* [V5] `openbb-federal-reserve`: Refactor for V5 + FFIEC & Regional Fed Routers (#7561)
* refactor for v5
* add workflows
* fix bhcpr schema
* fix pyproject.toml
* fix test
* codespell
* ty
* fix pyproject
* fix ci environment
* fix ci environment
* convert documents endpoints to use fedinprint service
* codeql fix
* fix ny publications and add ny nowcast
* [V5] `openbb-eia`: Refactor for V5 + Add Full API Implementation (#7584)
* eia refactor for v5
* add test and publish workflows
* data browser state
* fix workflows
* more workflow test items
* temp files in tests
* more fixes
* more browsers
* fix codeql issues
* fix mer state and ensure multi-user capability
* uv lock
* table bridge
* fix electricity browser state
* convert HTML widgets to iFrame
* fix recursion error in workspace mcp
* [V5] `openbb-government-us`: Refactor for V5 + Move `uscongress` (#7604)
* v5 government_us provider
* workflow files
* ty and add xlrd to pyproject
* shrink test cassettes
* dev dependencies
* wheel packaging and more tests
* python 3.10 thing..?
* fix test
* default state for multiselect dropdowns
* replace congress html widgets with iframes
* fallback to congress api for congress before 108
* ensure member passage voting count populates
* [V5] Feature: Add JODI World Oil & Gas Database (#7593)
* add the JODI oil and gas database as a provider/router extension
* lint fixes
* add grouping to apps.json
---------
Co-authored-by: deeleeramone <>
* [V5] `openbb-cftc`: Refactor for V5 + Add OIS, Forward Curve, and FX Trade Analysis (#7615)
* refactor openbb-cftc for v5
* add SHIBOR to fixings
---------
Co-authored-by: deeleeramone <>
* fix for plotly 7 breaking change to schemas
* [V5] `openbb-tmx`: Refactor extension for V5 (#7668)
* refactor tmx provider for v5
* fix tests..
* fix tests stack trace exposure
* fix workflow setup
* [V5] `openbb-nasdaq`: Refactor provider for V5. (#7670)
* refactor nasdaq provider for v5
* add workflows
* ty fix
* [V5] `openbb-fred`: Refactor as V5 extension. (#7669)
* refactor fred extension for v5
* fix markdown MD037 lint error in README
* codespell
* [V5] `openbb-cboe`: Refactor for V5 + Add Payoff Diagrams (#7617)
* refactor cboe for v5
* use zoneinfo instead
* fix test
* some fixes related to the cache access
* fix a couple of tests
* fix the test coverage
* [V5] `openbb-deribit`: Refactor provider for V5 (#7674)
* refactor openbb-deribit for v5
* restructure the optimizer around the published combos data and add integration test suites
* fix markdown lint errors
* remove openbb-econdb provider (#7675)
* codespell
* [V5] `openbb-sec`: Refactor for V5 (#7525)
* refactor openbb-sec for v5
* linting
* ty
* normalize compare_company_facts
* add workflows
* python 3.10 fix
* add more thorough url validation and tests
* ty check
* ty for python 3.10
* reconfigure with apps
* temp pyproject overrride
* pyproject
* pyproject
* pyproject
* codespell
* pyproject
* fix uk schema file links in schema_files
* test coverage
* test_cli
* port #7547 into this pr
* no hard coded prefix
* improve filing rendering capabilities
* fix xml text detection
* make codeql happy
* missed a file in the commit
* test coverage
* codespell
* fix flakey test
* some updates
* management_ownership --> management_profiles
* proxy statement markdown
* ruff
* fix test coverage
* more tests
* test coverage
* fix lint errors
* Add SEC adviser search, profile, and universe routes (#7559)
* Add SEC institutional data fetchers
* Add SEC filing index and Form D routes
* Add SEC investment adviser people data
* Expose SEC adviser and institutional routes
* fix(sec): address adviser review feedback
* refactor(sec): narrow adviser support to IAPD search
* feat(sec): add adviser firm and individual searches
* feat(sec): add adviser profile drill-down
* fix: repair pre-commit configuration
* feat(sec): add adviser universe route
* feat(sec): expose complete Form ADV data
---------
Co-authored-by: Danglewood <85772166+deeleeramone@users.noreply.github.com>
* fix test coverage
---------
Co-authored-by: deeleeramone <>
Co-authored-by: Theodore Aptekarev <aptekarev@gmail.com>
* [V5] `openbb-finra`: Refactor for V5 (#7676)
* refactor finra provider for v5
* add workflows
* fix tests
* fix lint errors
* [V5] Update Repository License to Apache 2.0 (#7677)
* change license to Apache 2.0 for V5
* codespell
* bump devtools and repoint pyproject.toml
* Enterprise Flask Migration Toolkit (#7264)
* feat: Add Flask-to-OpenBB enterprise migration toolkit
- Implement Router.from_flask() for zero-touch legacy API conversion
- Enable 4-hour migration vs 6-month traditional rewrite (80% cost reduction)
- Generate FastAPI endpoints from Flask routes with automatic type validation
- Support enterprise consulting services and custom integration workflows
Demonstrates OpenBB's competitive advantage for institutional client migrations.
Complements existing Workspace applications like Open Portfolio suite.
* chore: Resolve .gitignore conflict and add common ignores
* refactor: implement Phase 1 Flask integration architecture- Move Flask conversion logic to openbb_core.app.utils.flask- Add Flask detection to extension_loader - Integrate with OpenBB's core extension system- Add Flask dependency validation with sys.modules checkingAddresses @deeleeramone's architectural feedback for native OpenBB integration.
* docs: add Flask direct entry point demos- demo_direct_flask_entry_point.py: shows direct Flask instance reference- example_pyproject_flask_entry.toml: demonstrates pyproject.toml configurationSupports new architecture eliminating wrapper extensions.
* Implement Phase 1 Flask adapter per code review feedback
- Minimal working implementation with Flask detection and router creation
- Removes complex code generation logic for focused Phase 1 delivery
- Establishes foundation for Phase 2 route conversion features
* Fix Flask integration: Replace missing FlaskToOpenBBAdapter with WSGIMiddleware
- Remove FlaskToOpenBBAdapter which was not implemented
- Use FastAPI's WSGIMiddleware to mount Flask apps directly
- Update extension_loader to use returned Router object
- Convert utils.py to utils/__init__.py to support flask submodule
- Addresses PR review comment about unhandled AttributeError
* Address PR review feedback: fix Flask imports and remove unused files
- Revert .gitignore to upstream/develop
- Remove AI-generated demo/example files and flask_adapter extension
- Move Flask/werkzeug imports inside conditional blocks to avoid ImportError
- Use lazy imports via __getattr__ in utils/flask/__init__.py
- Add Flask tests with @pytest.mark.skipif markers
* Force APIRouter.include_router to propagate Mount paths instead of dropping them. (#1)
This change ensures that mounted ASGI applications (such as Flask apps wrapped in WSGIMiddleware) are not dropped when `APIRouter.include_router` is called.
Previously, while the initial load in `ExtensionLoader` worked as expected, the `Mount` objects were ignored by FastAPI's default inclusion logic. This caused the mounted elements to be lost during the final application assembly, resulting in 404 errors for the Flask routes.
This fix overrides the inclusion logic to manually propagate `Mount` objects downstream.
* feat(flask): Add metadata layer for OpenAPI documentation
Enhance Flask-to-OpenBB converter with structured docstring parsing:
- Parse Google-style docstrings (summary, description, params)
- Extract parameter descriptions from Args section
- Pass metadata to Router.command() for rich OpenAPI docs
This addresses PR feedback to provide tangible benefit by generating
comprehensive API documentation for migrated Flask routes.
* feat(flask): Implement manual OpenAPI spec generation and attachment
- Add FlaskParam and FlaskRouteInfo TypedDict for type-safe metadata
- Implement OpenAPISpecGenerator for Flask→OpenAPI conversion
- Extract route metadata via FlaskIntrospector
- Attach generated spec to router.openbb_openapi_spec_ext
- Add unit tests (3/3 passing)
Addresses WSGI/mount boundary issue where Router.command metadata
cannot propagate through WSGIMiddleware. Spec attached to router
instance for downstream OpenAPI assembly.
Resolves maintainer feedback on PR #7264
* feat(flask): Implement FlaskMountRegistry for multiple mount support
- Add FlaskMountRegistry class for centralized OpenAPI spec management
- Replace router.openbb_openapi_spec_ext with registry pattern
- Support multiple Flask apps at different mount prefixes
- Automatic path rewriting in aggregated OpenAPI specs
- Add registry test case (4/4 tests passing)
Addresses @deeleeramone's feedback on PR #7264 regarding router
attribute fragility and multiple mount support. Registry provides
lifecycle-independent state management and enables enterprise
scenarios with multiple Flask apps.
Resolves: OpenBB-finance/OpenBB#7264 (partial)
* feat(core): load Flask apps as openbb_core_extension WSGI sub-apps
A Flask app referenced directly by an openbb_core_extension entry point is
mounted via WSGIMiddleware and its routes are introspected into openapi.json
(operationId/widgetId/mcp_tool, parameters, request bodies, security, widget_config
and HTML widgets) so the widget factory and MCP server can discover them.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test(core): 100% coverage for flask utils + flask dev dependency
Add branch-coverage tests for the Flask integration package (introspection,
AST analysis, docstrings, OpenAPI generation, registry, loader, detection) and
declare flask as a dev dependency so the suite runs in CI.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(flask): treat f-string returns as HTML responses
* fix(flask): mount via a2wsgi instead of deprecated starlette WSGIMiddleware
- add openbb-core[flask] extra (flask, a2wsgi); a2wsgi resolved through require_optional
- run the WSGI serve test through mount_flask_extensions; drop httpx importorskip
- core CI installs [pandas,flask] and httpx2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(flask): cover failed entry point loads; narrow optional types for ty
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Danglewood <85772166+deeleeramone@users.noreply.github.com>
Co-authored-by: deeleeramone <>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* perf(core): vectorize the date conversion in `basemodel_to_df` (#7622)
* perf(core): vectorize the date conversion in basemodel_to_df
basemodel_to_df ran three per-element Python passes over the date column of
every payload it converted. Series.apply(to_datetime) calls pandas.to_datetime
once per row, and because each call receives a lone scalar with no format hint,
pandas also re-runs its datetime format guesser on every row.
Parse the column in one call instead, and use vectorized comparisons for the
all-midnight test and the reduction to datetime.date.
The element-wise parse is kept as a fallback for one case that the vectorized
path cannot represent: a column mixing UTC offsets, such as a series crossing a
daylight-savings boundary. Parsing per element lets each value keep its own
offset and yields an object column of tz-aware Timestamps, whereas a single
to_datetime call over that column raises "Mixed timezones detected". That
behaviour is covered by test_to_df_daylight_savings and is preserved exactly.
Measured on a payload built through EquityHistoricalData, median of 9 under an
exclusive CPU lock:
250 daily bars 8.92 ms -> 1.46 ms
1,000 daily bars 32.93 ms -> 3.13 ms
5,000 daily bars 162.25 ms -> 12.23 ms
* fix a broken test - unrelated to this PR...
---------
Co-authored-by: Danglewood <85772166+deeleeramone@users.noreply.github.com>
Co-authored-by: deeleeramone <>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix lint errors
* fix oecd lint error
* [V5] `openbb-mcp-server`: Refactor for v5 - Spec, Config, Packaging, Test Coverage (#7495)
* stash some changes
* add more robust testing
* mypy
* point PR at V5
* introduce spec file
* codespell
* test fix
* fix workflow environment setup
* fix workflow environment setup
* fix workflow environment setup
* add pyyaml to dependencies
* split lint jobs
* fix workflow environment setup
* fix workflow environment setup
* workflow env setup
* workflow env setup
* clean up code comments
* add auth hook entrypoints
* codespell
* add codegen feature
* codespell
* move _unpack into dispatchers for consistency with codegen packages
* surface nested models in the response
* fix missing coverage in CI
* socrata updates
* test fix
* detect plotly output
* add --include and --exclude flags from generate-extension command
* refactor mcp server for v5
* test action
* fix tests
* tests
* test fixture name
* revert: drop unrelated provider/README changes from mcp-server refactor
Strip the changes to ``README.md`` and ``openbb_platform/providers/{imf,
tradingeconomics,yfinance}`` that snuck into 4ac3602 (refactor mcp server
for v5). Those files are out of scope for the mcp-server work and were
modified inadvertently — restore them to their pre-4ac3602 state.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* tests and workflows
* fix workflow
* tests and workflows
* tests and workflows
* tests and workflows
* tests and workflows
* beartype shim for 3.15
* muck around with beartype for 3.15
* muck around with beartype for 3.15
* no 3.15 runners for mcp-server
* update skill files
* cap test matrix at python 3.14
* merge branch v5
* no useless comments
* platform controller command description split
* merge URL overloads from path params
* exclude none and unset from model dump
* refactor for fastmcp4
* wrap markdown lines that are too long
---------
Co-authored-by: deeleeramone <>
Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* update pre-commit, noxfile, and uv locks
* fixes for docstring generation
* test coverage
* fix cli renderer helper with figure object JSON
* fix the figure font when rendered
* refactor cookiecutter
* bump cookiecutter packagge version
* fix sec test by adding fixture
* fix desktop environments test
* fix(cli): inherit path-item-level OpenAPI parameters (#7606)
* fix(cli): inherit path-item-level OpenAPI parameters
OpenAPI 3.x lets a Path Item Object declare `parameters` shared by every
operation under that path, with operation-level entries overriding them by
(name, in). The codegen read only `op.get("parameters")`, so inherited
parameters were dropped silently — the generated command kept the URL
template but had nothing registered to fill it.
Real-world impact: the Xero Accounting API declares its required
`xero-tenant-id` header once per path item across all 138 paths. Before this
change `build_command_spec` produced 78 commands and none carried the
header, so every generated command 403s. Nothing warned.
Adds `operation_parameters()`, which merges the two levels and dereferences
both, and routes the pre-computed spec path (`build_command_spec`) and the
interactive path (`build_command_index` -> `build_parser_from_operation`)
through it. Unresolvable refs, non-dict entries, and nameless parameters are
skipped as before. Specs without path-item parameters are unaffected.
* fix(cli): narrow path-item inheritance to path and query parameters
Per review: headers do not translate into a Python interface function, and
outbound headers are transport configuration
(SystemSettings.PythonSettings.http.headers, the CLI's -H) or a security-scheme
concern — not a per-command argument.
Path-item `in: header` and `in: cookie` entries are now dropped rather than
inherited. Operation-level parameters are untouched, so an operation that
declares its own header keeps today's behavior.
Scope is now only what cannot be supplied any other way: `in: path` and
`in: query` parameters declared on the Path Item Object.
* fix(cli): merge allOf compositions in response schemas
Response-schema $refs were already expanded by deref_schema, but a schema
whose top level is an allOf composition was left unflattened. The properties
live inside the members, so a consumer reading them off the composition finds
none. fetcher_gen._data_schema looks for `properties.results`, gets nothing,
and falls through to _unwrap_schema_envelopes, which handles oneOf and anyOf
but not allOf. The generated Data model ends up with no fields.
Codat's listBills response is `allOf: [{properties: {results}}, {properties:
{_links, pageNumber, pageSize, totalResults}}]`, which produced an empty model.
merge_allof collapses a composition of object subschemas into one object
schema: properties unioned with the first declaration winning on conflict,
required unioned, and sibling keywords on the allOf node outranking members.
A composition that is not a plain intersection of object subschemas (a member
carrying its own oneOf/anyOf, or a non-object type) is returned unchanged
rather than flattened into something the spec does not describe. Nested
compositions are merged too, so a $ref inside a merged member still resolves.
Applied to extract_response_schema and extract_response_schemas.
Measured across five specs that use the pattern, counting operations whose
response schema exposes readable properties: 244 of 588 before, 394 after
(Codat Accounting 36 to 88, Codat Banking 0 to 8, Codat Commerce 1 to 11,
Rebilly 62 to 104, Box 145 to 183).
* fix(cli): merge allOf compositions in request body schemas
Same gap as the response side. request_body_parameters needs `type: object`
or `properties` to flatten a body into parameters, and an allOf composition
carries neither, so a POST or PUT generated from one gets no body parameters
at all.
extract_request_body_schema now runs merge_allof after deref_schema, matching
extract_response_schema.
Across the same five specs, counting request bodies that produce parameters:
138 of 206 before, 177 after (Codat Accounting 1 to 26 of its 30 bodies,
Rebilly 50 to 63, Box 87 to 88).
* fix(cli): stop annotation keywords blocking an allOf merge
_mergeable_allof_members required every member's keys to come from a small
whitelist, so any unrecognized keyword disqualified the whole composition.
Codat's PagingInfo carries `definitions` next to its properties, which meant
the live Codat-Accounting.yaml never merged and its response models stayed
empty. The APIs.guru copy of the same API omits that key, which is why the
earlier measurements looked clean.
The check is now a blocklist. A member blocks the merge when it carries its
own combinator (oneOf/anyOf/allOf/not), an unresolved $ref left by a cycle,
a scalar or array constraint (enum/const/items), or a type other than object.
Annotation keywords that do not affect the intersection (title, description,
examples, definitions, additionalProperties, vendor x- keys) no longer block
it, and are dropped from the merged result along with the members.
On the live Codat spec, operations whose response schema exposes readable
properties go from 36 of 127 to 114. Across the five specs measured earlier:
244 of 588 before, 488 after (was 394 under the whitelist).
Corpus check over 2,816 response and request-body extractions in 6 specs:
567 changed, every one attributable to an allOf in the pre-merge output,
0 unexplained. Twilio declares no allOf compositions and is unchanged.
* fix(cli): emit a name once when it is both a parameter and a body property
_signature_params appended request-body properties and then operation
parameters without deduplicating, so a name declared in both places produced
two function parameters with the same name and the generated module raised
SyntaxError.
Codat's push endpoints hit this: /push/bankAccounts/{accountId}/bankTransactions
declares accountId as a path parameter and repeats it in the request body, so
connections.push.bankAccounts.bankTransactions generated `accountId: str` and
`accountId: str = None` in one signature.
The operation parameter wins, since it carries the path placeholder and its own
required flag. The request payload is unchanged: _render_body_block derives body
fields from request_body_schema rather than from this list, so accountId is
still sent in the body.
This predates the path-item and allOf work in this PR. On origin/v5 the same
spec produces the same duplicate; on this branch the Codat spec generates 91
commands with 0 duplicated signatures, down from 1.
* fix(cli): use the safe identifier for keyword-named body properties
_render_body_block already writes `{'from': from_}`, keying the payload by the
wire name and reading a safe identifier, but _signature_params declared the raw
name. A body property named after a Python keyword therefore emitted
`from: str = None` and the generated module raised SyntaxError.
This did not fire before the allOf work in this PR, because a composed request
body produced no body parameters at all. Codat's Transfer body is such a
composition and has a `from` property, so connections.push.transfers started
generating invalid syntax once the body flattened.
_signature_params now emits safe_field_name(name)[0], the same helper
fetcher_gen and pydantic_gen already use. The body/query split in
_render_body_block compares against the safe names too: it previously matched
signature names against wire names, so `from_` was not recognized as a body
field and leaked into the query string as a `from_` parameter.
End to end on Codat-Accounting.yaml, generating the full extension and byte
compiling every emitted module: 136 files, 0 failures. origin/v5 emits 1 file
that does not compile (duplicate argument 'accountId').
Known gap left alone: an operation parameter named after a keyword would still
break, since the query and path lines use the raw name as a local variable.
None of the five specs checked declares one.
* fix(cli): do not treat instance data as schema, and sanitize POST identifiers
Two defects found by generating extensions from real specs and byte compiling
every emitted module.
merge_allof and deref_schema walked every value in a schema dict, including
keywords that hold instance data rather than subschemas. A `default` of
`{"allOf": [{"a": 1}, {"b": 2}]}` was rewritten to `{"type": "object"}`, and a
`$ref` key inside an example was resolved as if it were a reference. Both now
copy `default`, `example`, `examples`, `enum`, `const` and vendor `x-` values
through untouched.
_signature_params emitted operation parameter names verbatim, so a wire name
that is not a Python identifier produced invalid syntax. Rebilly declares an
`Organization-Id` header and a `REB-APIKEY` security header, which emitted
`REB-APIKEY: str = None`. It now uses safe_field_name, matching fetcher_gen,
and _render_body_block keys the query dict by the wire name while reading the
safe identifier so the request is unchanged.
This surfaced through the allOf work rather than being caused by it: a composed
request body used to yield no properties, so `has_body` was false in
package_gen and the command was generated as a GET fetcher, where identifiers
were already sanitized. With bodies flattening, 14 of Codat's 24 POST endpoints
correctly move to the POST path, which had never been exercised by these specs.
Generating the full extension and compiling every module:
Codat-Accounting.yaml v5: 1 failure branch: 0 (136 files)
Rebilly 2.1 v5: 14 failures branch: 1 (97 files)
The one remaining Rebilly failure is `async def 3dsecure(` in the router, which
fails identically on v5. Sanitizing that is a naming decision about the command
surface, so it is left alone here.
Known gap: a non-credential header parameter is still placed in the query
string on the POST path. Pre-existing, and unchanged by this PR.
* fix(cli): resolve signature collisions on the emitted identifier
The previous dedupe compared wire names, but the failure it prevents depends on
the sanitized identifier. Two wire names can collapse onto one (`from` and
`from_`, `Organization-Id` and `Organization_Id`), and operation_parameters
keeps a name declared twice under different `in` locations by design, so both
cases could still emit a duplicate argument.
_signature_params now tracks the identifiers it has emitted and skips anything
that would repeat one. Emission order is unchanged, so existing signatures are
byte-identical; only genuinely colliding names are dropped, and operation
parameters still win over body properties.
Neither collision occurs in Codat-Accounting.yaml or Rebilly 2.1. This closes
the class rather than the instance, since the instances are what this PR has
been finding.
Generation unchanged: Codat 136 files 0 failures, Rebilly 97 files 1 failure
(`async def 3dsecure(`, identical on v5).
* test(cli): cover the instance-data guard, and drop a redundant branch
Mutation checking the new tests (revert each fix, confirm its test fails)
found two problems with the previous commits.
The guard that stops merge_allof and deref_schema from walking into `default`,
`example`, `enum`, `const` and vendor values shipped with no test at all. It is
now covered from both directions: a default holding an `allOf` key keeps its
value, and a `$ref` inside a default stays a literal while a real reference
beside it still resolves.
The body-property loop also carried a redundant check. It tested the raw wire
name against the operation parameter names before testing the sanitized one,
but `operation_param_names` holds both forms and `safe_field_name` is idempotent
on identifiers, so the first test could never fire alone. Reverting it left
every test passing, which is what surfaced it. Removed, and the sanitized name
is now computed once.
All nine mutations are caught by the suite: both data guards, the allOf
blocklist in each direction, the response merge wiring, body/parameter
precedence, the keyword identifier, the body/query split and the
emitted-identifier dedupe.
cli/tests at 1,954 passed against the same 80 environment failures as the base.
Generation unchanged: Codat 136 files 0 failures, Rebilly 97 files 1 failure.
* fix(cli): route required path-item headers/cookies through credential handling
* fix(cli): narrow Popen.stdin/stdout for ty in run_in_obb fixture
---------
Co-authored-by: deeleeramone <>
* fix warnings raised by federal_reserve on some xlsx workbook parsing
* fix publish workflows
* test publish pipeline with openbb-core-v2.0.0rc0 (#7681)
* fix publish workflows with missing tag job, some tests and bump fastapi
* fix python 3.10 test failure in core
* actually fix python 3.10 test failure in core
* deincrement openbb-core version to allow release pattern
* bump core version (#7682)
* remove uv.sources from pyproject.toml files and point to openbb-core-2.0.0 on PyPI
* remove oecd cache asset that wasn't supposed to be commuitted
* remove poetry.lock, update pyproject.tml and uv.lock for openbb-platform-api-v2.0.0 (#7684)
Co-authored-by: deeleeramone <>
* add the missing publish-openbb-platform-api.yml workflow file
* remove poetry.lock, update pyproject.tml and uv.lock for openbb-platform-api-v2.0.0 (#7685)
Co-authored-by: deeleeramone <>
* fix the tag generation after publish
* [Release] `openbb-charting-v4.0.0` (#7686)
* up openbb-charting to 4.0.0 and fill missing test coverage in chart_style
* test coverage and format markdown files
---------
Co-authored-by: deeleeramone <>
* remove scikit-learn from dependencies, update tests, and format markdown files (#7687)
Co-authored-by: deeleeramone <>
* [Release] `openbb-cli-v2.0.0` (#7692)
* fix missing test coverage in openbb_cli
* fix conftest
---------
Co-authored-by: deeleeramone <>
* add missing publish-openbb-cli.yml workflow
* [Release] `openbb-cli-v2.0.0` (#7693)
* fix missing test coverage in openbb_cli
* fix conftest
---------
Co-authored-by: deeleeramone <>
* test fixes for cli...
* [Release] `openbb-cli-v.2.0.0` (#7694)
* fix missing test coverage in openbb_cli
* fix conftest
---------
Co-authored-by: deeleeramone <>
* fix hanging mcp server test
* [Release] `openbb-mcp-server-v2.0.0` (#7695)
Co-authored-by: deeleeramone <>
* update uv.lock (#7696)
Co-authored-by: deeleeramone <>
* release openbb-commodity-v2.0.0 (#7697)
Co-authored-by: deeleeramone <>
* update uv.lock (#7698)
Co-authored-by: deeleeramone <>
* update uv.lock (#7699)
Co-authored-by: deeleeramone <>
* update uv.lock (#7700)
Co-authored-by: deeleeramone <>
* [Release] `openbb-econometrics-v2.0.0` (#7701)
* update uv.lock
* update pyproject.toml
---------
Co-authored-by: deeleeramone <>
* update uv.lock (#7703)
Co-authored-by: deeleeramone <>
* update uv.lock (#7702)
Co-authored-by: deeleeramone <>
* update uv.lock (#7704)
Co-authored-by: deeleeramone <>
* update uv.lock (#7705)
Co-authored-by: deeleeramone <>
* update uv.lock (#7706)
Co-authored-by: deeleeramone <>
* [V5] Fix a Number of V5 Bugs (#7707)
* fix a number of v5 bugs
* fix lint error
* more lint
* fixes for quantitative, technical, charting, famafrench for version bumps
---------
Co-authored-by: deeleeramone <>
* update uv.lock (#7709)
Co-authored-by: deeleeramone <>
* update uv.lock (#7708)
Co-authored-by: deeleeramone <>
* update uv.lock (#7710)
Co-authored-by: deeleeramone <>
* update uv.lock (#7711)
Co-authored-by: deeleeramone <>
* [Release] `openbb-cboe-v2.0.0` (#7713)
Co-authored-by: deeleeramone <>
* update uv.lock (#7714)
Co-authored-by: deeleeramone <>
* update uv.lock (#7717)
Co-authored-by: deeleeramone <>
* [Release] `openbb-ecb-v2.0.0` (#7715)
* update uv.lock
* update uv.lock
---------
Co-authored-by: deeleeramone <>
* update uv.lock (#7718)
Co-authored-by: deeleeramone <>
* update uv.lock (#7719)
Co-authored-by: deeleeramone <>
* update uv.lock (#7720)
Co-authored-by: deeleeramone <>
* add missing publish-openbb-fred.yml to repo
* update uv.lock (#7723)
Co-authored-by: deeleeramone <>
* update uv.lock (#7722)
Co-authored-by: deeleeramone <>
* update uv.lock (#7716)
Co-authored-by: deeleeramone <>
* update uv.lock (#7724)
Co-authored-by: deeleeramone <>
* update uv.lock (#7726)
Co-authored-by: deeleeramone <>
* [Release] `openbb-fred-v2.0.0` (#7725)
* update uv.lock
* fix tests and dependencies
* fix test_fred_fetchers
---------
Co-authored-by: deeleeramone <>
* [Release] `openbb-imf-v3.0.0` (#7727)
* update uv.lock
* fix tests and dependencies
* fix test_fred_fetchers
* bump openbb-imf version to 3.0.0
---------
Co-authored-by: deeleeramone <>
* update uv.lock (#7729)
Co-authored-by: deeleeramone <>
* update uv.lock (#7728)
Co-authored-by: deeleeramone <>
* update uv.lock (#7730)
Co-authored-by: deeleeramone <>
* update uv.lock (#7731)
Co-authored-by: deeleeramone <>
* update uv.lock (#7732)
Co-authored-by: deeleeramone <>
* update uv.lock (#7733)
Co-authored-by: deeleeramone <>
* cookiecutter pyproject and lock
* update pyproject.toml (#7734)
Co-authored-by: deeleeramone <>
* update pyproject.toml (#7735)
Co-authored-by: deeleeramone <>
* update uv.lock (#7736)
Co-authored-by: deeleeramone <>
* fix some sec issues, allegedly (#7737)
Co-authored-by: deeleeramone <>
* update uv.lock (#7738)
Co-authored-by: deeleeramone <>
* update main pyproject.toml
* lint fixes
* update assets (#7739)
Co-authored-by: deeleeramone <>
* [Release] `openbb-v5.0.0` (#7740)
* update assets
* add missing workflow
---------
Co-authored-by: deeleeramone <>
---------
Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: deeleeramone <>
Co-authored-by: Theodore Aptekarev <aptekarev@gmail.com>
Co-authored-by: Dex <i@dex.moe>
Co-authored-by: Monte Hoover <monte.b.hoover@gmail.com>
Co-authored-by: Boris Li <65254053+BorisQuanLi@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Lucas Zimmermann <lucaszmayer@gmail.com>
2785 lines
105 KiB
Python
2785 lines
105 KiB
Python
"""Tests for the EIA data-browser widgets and reverse proxy."""
|
|
|
|
import json
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from fastapi import Request
|
|
|
|
from openbb_us_eia import browsers
|
|
|
|
MAPS_HTML = """
|
|
<ul>
|
|
<li><label for="maps-eia-tab-1"><b>Recent</b> maps</label></li>
|
|
<li><label for="maps-eia-tab-2">Historical maps</label></li>
|
|
</ul>
|
|
<div class="tab-content">
|
|
<h1>Lower 48</h1>
|
|
<h2 class="list-header">Overview & boundaries</h2>
|
|
<a href="/maps/images/shale_2023.pdf" title="Shale plays, Lower 48 States (12/13/2023)(pdf)">PDF</a>
|
|
<a href="/maps/images/shale_2023.jpg" title="Shale plays, Lower 48 States (12/13/2023)(jpg)">JPG</a>
|
|
<a href="/maps/images/tight_gas.pdf" title="Major tight gas plays(pdf)">PDF</a>
|
|
</div>
|
|
<div class="tab-content">
|
|
<h2>Historical basins</h2>
|
|
<a href="/maps/images/old_map.jpg" title="Historic basin map (6/1/2011)(jpg)">JPG</a>
|
|
<a href="/maps/data.zip" title="Ignored (1/1/2011)(zip)">ZIP</a>
|
|
<a href="/maps/images/field_maps/app_boe.pdf" title="Historical basins: Western Area BOE (pdf)">BOE</a>
|
|
</div>
|
|
"""
|
|
|
|
PROXY_PREFIX = "/api/v1/eia_proxy"
|
|
INTERNATIONAL = browsers.EIA_DATA_BROWSERS["international"]["path"]
|
|
IMPORTS = browsers.EIA_DATA_BROWSERS["petroleum_imports"]["path"]
|
|
ELECTRICITY = browsers.EIA_DATA_BROWSERS["electricity"]["path"]
|
|
|
|
|
|
def make_info(path, query="", method="GET", body=b"", referer="", user=""):
|
|
"""Build the request_info mapping the proxy endpoints consume."""
|
|
return {
|
|
"url": f"http://test{path}",
|
|
"query": query,
|
|
"path": path,
|
|
"method": method,
|
|
"body": body,
|
|
"content_type": "application/x-www-form-urlencoded" if body else "",
|
|
"referer": referer,
|
|
"user": user,
|
|
}
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def isolate_state(monkeypatch, tmp_path):
|
|
"""Keep every test off the shared caches, disk, and view stores."""
|
|
monkeypatch.setattr(
|
|
"openbb_core.app.utils.get_user_cache_directory",
|
|
lambda: str(tmp_path / "cache"),
|
|
)
|
|
monkeypatch.setattr(browsers, "_MAPS_CATALOG", None)
|
|
stores = (
|
|
browsers._PROXY_CACHE,
|
|
browsers._REDIRECTS,
|
|
browsers._REWRITE_CACHE,
|
|
browsers._TABLE_ROWS,
|
|
browsers._LAST_DATA,
|
|
browsers._VIEW_DATA,
|
|
browsers._CURRENT_VIEW,
|
|
browsers._WIDGET_STATE,
|
|
browsers._INTL_LABELS,
|
|
browsers._IMPORTS_LABELS,
|
|
)
|
|
for store in stores:
|
|
store.clear()
|
|
yield
|
|
for store in stores:
|
|
store.clear()
|
|
|
|
|
|
class FakeUpstream:
|
|
"""Stand in for eia.gov at the one place the code touches the network."""
|
|
|
|
def __init__(self, responses):
|
|
self.responses = responses
|
|
self.calls = []
|
|
|
|
def __call__(self, target):
|
|
self.calls.append(target)
|
|
result = self.responses[target]
|
|
if isinstance(result, BaseException):
|
|
raise result
|
|
body, content_type = result[0], result[1]
|
|
status = result[2] if len(result) > 2 else 200
|
|
landed = result[3] if len(result) > 3 else target
|
|
return body, content_type, status, landed
|
|
|
|
def install(self, monkeypatch):
|
|
monkeypatch.setattr(browsers, "_fetch_sync", self)
|
|
return self
|
|
|
|
|
|
class TestRequestInfo:
|
|
"""The proxy reads deepcopy-safe primitives off the request."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_extracts_url_query_path_and_method(self):
|
|
request = Request(
|
|
scope={
|
|
"type": "http",
|
|
"method": "GET",
|
|
"scheme": "http",
|
|
"server": ("test", 80),
|
|
"path": "/api/v1/eia_proxy/coal/",
|
|
"query_string": b"a=1",
|
|
"headers": [(b"x-openbb-user", b"analyst@example.com")],
|
|
}
|
|
)
|
|
info = await browsers.request_info(request)
|
|
assert info["url"] == "http://test/api/v1/eia_proxy/coal/"
|
|
assert info["query"] == "a=1"
|
|
assert info["path"] == "/api/v1/eia_proxy/coal/"
|
|
assert info["method"] == "GET"
|
|
assert "analyst@example.com" not in json.dumps(info, default=str)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_the_user_is_stashed_as_an_opaque_key(self):
|
|
def info_for(headers):
|
|
return browsers.request_info(
|
|
Request(
|
|
scope={
|
|
"type": "http",
|
|
"method": "GET",
|
|
"scheme": "http",
|
|
"server": ("test", 80),
|
|
"path": "/api/v1/coal_browser",
|
|
"query_string": b"",
|
|
"headers": headers,
|
|
}
|
|
)
|
|
)
|
|
|
|
alice = (await info_for([(b"x-openbb-user", b"alice@x.com")]))["user"]
|
|
bob = (await info_for([(b"x-openbb-user", b"bob@x.com")]))["user"]
|
|
assert alice and bob and alice != bob
|
|
assert "alice@x.com" not in alice
|
|
assert (await info_for([]))["user"] == ""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_post_body_is_read(self):
|
|
async def receive():
|
|
return {"type": "http.request", "body": b"a=1", "more_body": False}
|
|
|
|
request = Request(
|
|
scope={
|
|
"type": "http",
|
|
"method": "POST",
|
|
"scheme": "http",
|
|
"server": ("test", 80),
|
|
"path": "/api/v1/eia_proxy/x",
|
|
"query_string": b"",
|
|
"headers": [(b"content-type", b"application/json")],
|
|
},
|
|
receive=receive,
|
|
)
|
|
info = await browsers.request_info(request)
|
|
assert info["method"] == "POST"
|
|
assert info["body"] == b"a=1"
|
|
assert info["content_type"] == "application/json"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unreadable_post_body_is_empty(self):
|
|
async def receive():
|
|
raise RuntimeError("disconnected")
|
|
|
|
request = Request(
|
|
scope={
|
|
"type": "http",
|
|
"method": "POST",
|
|
"scheme": "http",
|
|
"server": ("test", 80),
|
|
"path": "/api/v1/eia_proxy/x",
|
|
"query_string": b"",
|
|
"headers": [],
|
|
},
|
|
receive=receive,
|
|
)
|
|
assert (await browsers.request_info(request))["body"] == b""
|
|
|
|
|
|
class TestSplitWidgetParams:
|
|
"""Widget markers are consumed by the proxy, never forwarded upstream."""
|
|
|
|
def test_splits_markers_and_keeps_the_rest(self):
|
|
query = (
|
|
f"obb_theme=dark&obb_browser={INTERNATIONAL}&obb_user=abc123"
|
|
"&obb_view=international%2Fdata&obb_seq=1700&frequency=A&pid=44"
|
|
)
|
|
rest, dark, browser, view, seq, user = browsers._split_widget_params(query)
|
|
assert rest == "frequency=A&pid=44"
|
|
assert dark is True
|
|
assert browser == INTERNATIONAL
|
|
assert view == "international/data"
|
|
assert seq == 1700.0
|
|
assert user == "abc123"
|
|
|
|
def test_light_theme_and_unknown_browser_are_dropped(self):
|
|
rest, dark, browser, view, seq, user = browsers._split_widget_params(
|
|
"obb_theme=light&obb_browser=bogus"
|
|
)
|
|
assert rest == ""
|
|
assert dark is False
|
|
assert browser == ""
|
|
assert view == ""
|
|
assert seq == 0.0
|
|
assert user == ""
|
|
|
|
def test_a_malformed_sequence_is_ignored(self):
|
|
assert browsers._split_widget_params("obb_seq=nope")[4] == 0.0
|
|
|
|
|
|
class TestDeriveBrowser:
|
|
"""Angular SPAs issue untagged requests, so the path must identify the app."""
|
|
|
|
def test_maps_app_root_to_browser(self):
|
|
assert browsers._derive_browser("international/api/data/data") == INTERNATIONAL
|
|
assert (
|
|
browsers._derive_browser("states/api/x")
|
|
== (browsers.EIA_DATA_BROWSERS["states"]["path"])
|
|
)
|
|
|
|
def test_outlooks_keeps_two_segments(self):
|
|
assert browsers._app_root("outlooks/steo/data/browser/") == "outlooks/steo/"
|
|
|
|
def test_unknown_path_has_no_browser(self):
|
|
assert browsers._derive_browser("nowhere/x") == ""
|
|
|
|
|
|
class TestIsDataResponse:
|
|
"""Only a view's table payload counts as its data."""
|
|
|
|
@pytest.mark.parametrize(
|
|
"target",
|
|
[
|
|
"https://www.eia.gov/international/api/data/data?x=1",
|
|
"https://www.eia.gov/international/api/ranking/latestWorldTop",
|
|
"https://www.eia.gov/coal/data/browser/data/index.php?x=1",
|
|
],
|
|
)
|
|
def test_table_payloads_are_data(self, target):
|
|
assert browsers.is_data_response(target, "application/json") is True
|
|
|
|
@pytest.mark.parametrize(
|
|
"target",
|
|
[
|
|
"https://www.eia.gov/international/api/articles/getLatestArticles",
|
|
"https://www.eia.gov/international/api/countries/data",
|
|
"https://www.eia.gov/states/api/Projects/States/Json/geo.json",
|
|
"https://www.eia.gov/global/x.json",
|
|
"https://www.eia.gov/coal/data/browser/?method=getConfig",
|
|
],
|
|
)
|
|
def test_config_and_chrome_payloads_are_not_data(self, target):
|
|
assert browsers.is_data_response(target, "application/json") is False
|
|
|
|
def test_non_json_is_never_data(self):
|
|
assert browsers.is_data_response("https://www.eia.gov/x", "text/html") is False
|
|
|
|
def test_ngqs_report_config_and_report_table_are_distinguished(self):
|
|
base = "https://www.eia.gov/naturalgas/ngqs/data/report"
|
|
assert browsers.is_data_response(base, "application/json") is False
|
|
assert browsers.is_data_response(f"{base}/1", "application/json") is True
|
|
|
|
|
|
class TestRenderedTable:
|
|
"""``raw`` serves the table the page has rendered -- the current view itself."""
|
|
|
|
ROWS = [
|
|
{"category": "All grades", "2024": None},
|
|
{"category": "World", "2024": None},
|
|
{"category": "Total U.S.", "2024": 2410516.0},
|
|
]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_the_page_publishes_its_table(self):
|
|
info = make_info(
|
|
"/api/v1/eia_table",
|
|
query=f"obb_browser={IMPORTS}&obb_seq=2000",
|
|
method="POST",
|
|
body=json.dumps(self.ROWS).encode(),
|
|
)
|
|
response = await browsers.eia_table(info)
|
|
assert response.status_code == 204
|
|
assert browsers.get_table_rows(IMPORTS) == self.ROWS
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_raw_serves_the_rendered_table_verbatim(self, monkeypatch):
|
|
browsers.set_table_rows(IMPORTS, self.ROWS, 2000)
|
|
spec = browsers.EIA_DATA_BROWSERS["petroleum_imports"]
|
|
assert await browsers.raw_table("petroleum_imports", spec) == self.ROWS
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_imports_raw_prefers_the_hierarchical_payload(self, monkeypatch):
|
|
spec = browsers.EIA_DATA_BROWSERS["petroleum_imports"]
|
|
target = f"{browsers._EIA_ORIGIN}/petroleum/imports/browser/data/index.php?x=1"
|
|
payload = {
|
|
"TABLE_DATA": [
|
|
{
|
|
"seriesID": "PET_IMPORTS.WORLD-RP_3-LSO.A",
|
|
"data": {"2009": 100, "2011": 300, "2010": 200},
|
|
}
|
|
]
|
|
}
|
|
FakeUpstream(
|
|
{
|
|
f"{browsers._EIA_ORIGIN}/{browsers._IMPORTS_CONFIG}": (
|
|
json.dumps(IMPORTS_CONFIG).encode(),
|
|
"application/json",
|
|
),
|
|
target: (json.dumps(payload).encode(), "application/json"),
|
|
}
|
|
).install(monkeypatch)
|
|
browsers._IMPORTS_LABELS.clear()
|
|
browsers.set_table_rows(IMPORTS, [{"category": "Texas", "2009 ": 1}], 2000)
|
|
browsers.set_current_view(spec["path"], "imports/view")
|
|
browsers.set_data_target(
|
|
spec["path"], "imports/view", {"url": target, "method": "GET"}
|
|
)
|
|
rows = await browsers.raw_table("petroleum_imports", spec)
|
|
assert rows[0]["category"] == (
|
|
"Imports of Light Sour from World to PADD3 (Gulf Coast), annual"
|
|
)
|
|
assert rows[0]["origin"] == "World"
|
|
assert rows[0]["destination"] == "PADD3 (Gulf Coast)"
|
|
assert rows[0]["grade"] == "Light Sour"
|
|
assert list(rows[0])[-3:] == ["2009 ", "2010 ", "2011 "]
|
|
browsers._IMPORTS_LABELS.clear()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_later_table_replaces_an_earlier_one(self):
|
|
browsers.set_table_rows(IMPORTS, self.ROWS, 2000)
|
|
newer = [{"category": "Light Sweet", "2024": 7.0}]
|
|
browsers.set_table_rows(IMPORTS, newer, 3000)
|
|
assert browsers.get_table_rows(IMPORTS) == newer
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_table_from_a_view_the_user_left_is_ignored(self):
|
|
browsers.set_table_rows(IMPORTS, self.ROWS, 3000)
|
|
stale = [{"category": "STALE", "2024": 1.0}]
|
|
browsers.set_table_rows(IMPORTS, stale, 1000)
|
|
assert browsers.get_table_rows(IMPORTS) == self.ROWS
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_an_unknown_browser_publishes_nothing(self):
|
|
await browsers.eia_table(
|
|
make_info(
|
|
"/api/v1/eia_table",
|
|
query="obb_browser=bogus",
|
|
method="POST",
|
|
body=json.dumps(self.ROWS).encode(),
|
|
)
|
|
)
|
|
assert not browsers._TABLE_ROWS
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_malformed_body_publishes_nothing(self):
|
|
for body in (b"not json", b"[]", b'["junk"]', b'{"a":1}'):
|
|
await browsers.eia_table(
|
|
make_info(
|
|
"/api/v1/eia_table",
|
|
query=f"obb_browser={IMPORTS}",
|
|
method="POST",
|
|
body=body,
|
|
)
|
|
)
|
|
assert not browsers._TABLE_ROWS
|
|
|
|
def test_the_extractor_is_injected_into_the_page(self):
|
|
page = browsers.rewrite_html("<head></head>", PROXY_PREFIX, browser=IMPORTS)
|
|
assert browsers._table_bridge_js(IMPORTS) in page
|
|
assert "eia:table" in page
|
|
assert "/eia_table" in page
|
|
assert "forEachNodeAfterFilterAndSort" in page
|
|
|
|
def test_the_extractor_is_not_injected_without_a_browser(self):
|
|
page = browsers.rewrite_html("<head></head>", PROXY_PREFIX)
|
|
assert "eia:table" not in page
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_raw_does_not_fall_back_to_the_default_view(self, monkeypatch):
|
|
spec = browsers.EIA_DATA_BROWSERS["electricity"]
|
|
browsers.set_table_rows(ELECTRICITY, self.ROWS, 2000)
|
|
|
|
def unused(_target):
|
|
raise AssertionError("raw must not re-fetch when a table was published")
|
|
|
|
monkeypatch.setattr(browsers, "_fetch_sync", unused)
|
|
assert await browsers.raw_table("electricity", spec) == self.ROWS
|
|
|
|
|
|
NODE = shutil.which("node") or "/usr/local/bin/node"
|
|
|
|
HARNESS = """
|
|
var posted = null, sent = null;
|
|
var listeners = {};
|
|
global.window = global;
|
|
global.location = { pathname: "/api/v1/eia_proxy/electricity/data/browser/",
|
|
search: "", hash: "", origin: "http://test" };
|
|
global.document = {
|
|
documentElement: {},
|
|
querySelectorAll: function () { return []; },
|
|
};
|
|
global.MutationObserver = function () { return { observe: function () {} }; };
|
|
global.fetch = function (url, init) {
|
|
sent = {
|
|
url: url,
|
|
body: init && init.body,
|
|
keepalive: !!(init && init.keepalive),
|
|
};
|
|
return { catch: function () {} };
|
|
};
|
|
window.parent = { postMessage: function (msg) { posted = msg; } };
|
|
window.addEventListener = function (name, fn) { listeners[name] = fn; };
|
|
|
|
__BRIDGE__
|
|
|
|
__GRID__
|
|
|
|
setTimeout(function () {
|
|
process.stdout.write(JSON.stringify({ posted: posted, sent: sent }), function () {
|
|
process.exit(0);
|
|
});
|
|
}, 1600);
|
|
"""
|
|
|
|
XHR_TAG_HARNESS = """
|
|
var beacon = null;
|
|
global.window = global;
|
|
global.location = {
|
|
pathname: "/api/v1/eia_proxy/totalenergy/data/browser/",
|
|
search: "__SEARCH__", hash: "#/?f=M", origin: "http://test",
|
|
href: "http://test/api/v1/eia_proxy/totalenergy/data/browser/",
|
|
};
|
|
global.document = { baseURI: location.href };
|
|
var store = __STORE__;
|
|
global.sessionStorage = {
|
|
getItem: function (k) { return k in store ? store[k] : null; },
|
|
setItem: function (k, v) { store[k] = v; },
|
|
};
|
|
try { global.navigator = { sendBeacon: function (u) { beacon = u; return true; } }; }
|
|
catch (e) { /* node ships a read-only navigator; the beacon falls back to fetch */ }
|
|
global.history = { pushState: function () {}, replaceState: function () {} };
|
|
global.XMLHttpRequest = function () {};
|
|
global.XMLHttpRequest.prototype = { open: function () {} };
|
|
global.fetch = function (url) {
|
|
if (typeof url === "string" && url.indexOf("/eia_view") >= 0) beacon = url;
|
|
return { catch: function () {} };
|
|
};
|
|
window.addEventListener = function () {};
|
|
|
|
__SCRIPT__
|
|
|
|
process.stdout.write(JSON.stringify({ beacon: beacon, store: store }));
|
|
"""
|
|
|
|
|
|
@pytest.mark.skipif(not Path(NODE).exists(), reason="node is not installed")
|
|
class TestProxyRequestTagger:
|
|
"""The tagger keeps every proxied request bound to its user across reloads."""
|
|
|
|
def run(self, search, store):
|
|
script = (
|
|
browsers._xhr_tag_js("totalenergy/data/browser/", "")
|
|
.replace("<script>", "")
|
|
.replace("</script>", "")
|
|
)
|
|
source = (
|
|
XHR_TAG_HARNESS.replace("__SEARCH__", search)
|
|
.replace("__STORE__", json.dumps(store))
|
|
.replace("__SCRIPT__", script)
|
|
)
|
|
result = subprocess.run( # noqa: S603
|
|
[NODE, "-e", source],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=30,
|
|
check=True,
|
|
)
|
|
return json.loads(result.stdout)
|
|
|
|
def test_the_user_key_is_persisted_on_the_first_load(self):
|
|
out = self.run("?obb_theme=dark&obb_user=HASH123", {})
|
|
assert out["store"]["obb_user"] == "HASH123"
|
|
assert "obb_user=HASH123" in out["beacon"]
|
|
|
|
def test_the_user_key_survives_a_query_clobbering_navigation(self):
|
|
out = self.run("?tbl=T09.05", {"obb_user": "HASH123"})
|
|
assert "obb_user=HASH123" in out["beacon"]
|
|
|
|
|
|
@pytest.mark.skipif(not Path(NODE).exists(), reason="node is not installed")
|
|
class TestHostRestore:
|
|
"""The host rebuilds the iframe src from the saved nav, per browser."""
|
|
|
|
def run(self, browser, src, nav):
|
|
html = browsers._TEMPLATE.read_text(encoding="utf-8")
|
|
funcs = html[html.index("var storeKey") : html.index("function reportView")]
|
|
data = {"browser": browser, "user": "U9", "src": src}
|
|
store = {f"eia:view:{browser}:U9": json.dumps(nav)}
|
|
source = (
|
|
"global.window = global;\n"
|
|
f"var _store = {json.dumps(store)};\n"
|
|
"global.localStorage = { getItem: function (k) { return _store[k] || null; },"
|
|
" setItem: function () {}, removeItem: function () {} };\n"
|
|
f"var DATA = {json.dumps(data)};\n"
|
|
f"{funcs}\n"
|
|
"process.stdout.write(restoredSrc());"
|
|
)
|
|
return subprocess.run( # noqa: S603
|
|
[NODE, "-e", source],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=30,
|
|
check=True,
|
|
).stdout
|
|
|
|
def test_mer_restores_the_full_state_including_the_range(self):
|
|
out = self.run(
|
|
"total_energy",
|
|
"http://h/api/v1/eia_proxy/totalenergy/data/browser/?obb_theme=dark&obb_user=U9",
|
|
{
|
|
"pathname": "/api/v1/eia_proxy/totalenergy/data/browser/",
|
|
"search": "?tbl=T09.01",
|
|
"hash": "#/?f=M&start=201506&end=202604&charted=1-2",
|
|
},
|
|
)
|
|
assert out.endswith(
|
|
"/totalenergy/data/browser/?obb_theme=dark&obb_user=U9&tbl=T09.01"
|
|
"#/?f=M&start=201506&end=202604&charted=1-2"
|
|
)
|
|
|
|
def test_other_browsers_restore_the_full_hash(self):
|
|
out = self.run(
|
|
"electricity",
|
|
"http://h/api/v1/eia_proxy/electricity/data/browser/?obb_theme=dark&obb_user=U9",
|
|
{
|
|
"pathname": "/api/v1/eia_proxy/electricity/data/browser/",
|
|
"search": "",
|
|
"hash": "#/topic/0?agg=2,0,1&fuel=vtvv",
|
|
},
|
|
)
|
|
assert out.endswith("#/topic/0?agg=2,0,1&fuel=vtvv")
|
|
|
|
def test_the_frame_is_nudged_to_repaint_the_grid(self):
|
|
html = browsers._TEMPLATE.read_text(encoding="utf-8")
|
|
nudge = html[html.index("function nudge()") : html.index("function ready()")]
|
|
source = (
|
|
"var dispatched = [];\n"
|
|
"global.Event = function (t) { this.type = t; };\n"
|
|
"var frame = { contentWindow: { dispatchEvent: function (e) {"
|
|
" dispatched.push(e.type); } } };\n"
|
|
"global.setTimeout = function (fn) { fn(); };\n"
|
|
f"{nudge}\n"
|
|
"nudge();\n"
|
|
"process.stdout.write(JSON.stringify({ dispatched: dispatched }));"
|
|
)
|
|
out = json.loads(
|
|
subprocess.run( # noqa: S603
|
|
[NODE, "-e", source],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=30,
|
|
check=True,
|
|
).stdout
|
|
)
|
|
assert out["dispatched"] == ["resize", "resize", "resize", "resize"]
|
|
|
|
|
|
@pytest.mark.skipif(not Path(NODE).exists(), reason="node is not installed")
|
|
class TestSlickGridExtraction:
|
|
"""The classic browsers draw with SlickGrid, and raw must read what they drew."""
|
|
|
|
GRID = """
|
|
function resolver(field, datum) {
|
|
var path = field.split('.');
|
|
var ret = datum;
|
|
for (var i = 0; i < path.length; i++) {
|
|
ret = ret[path[i]];
|
|
if (!ret || typeof ret == 'undefined') break;
|
|
}
|
|
return ret;
|
|
}
|
|
var descriptionOutputFormatter = function (row, cell, value, columnDef, datum) {
|
|
return datum.CHART_NAME || value;
|
|
};
|
|
var numericalDataFormatter = function (row, cell, value, columnDef, datum) {
|
|
if (!datum.HAS_DATA) return '';
|
|
var ret = isNaN(parseFloat(value))
|
|
? value
|
|
: Number(value).toFixed(datum.PRECISION !== undefined ? datum.PRECISION : 0)
|
|
.replace(/\\B(?=(\\d{3})+(?!\\d))/g, ',');
|
|
return ret === undefined || ret === '' || ret === null ? '--' : ret;
|
|
};
|
|
var COLUMNS = __COLUMNS__;
|
|
for (var i = 0; i < COLUMNS.length; i++) {
|
|
if (COLUMNS[i].outputFormatter === '@description')
|
|
COLUMNS[i].outputFormatter = descriptionOutputFormatter;
|
|
if (COLUMNS[i].dataFormatter === '@numerical')
|
|
COLUMNS[i].dataFormatter = numericalDataFormatter;
|
|
}
|
|
var OPTIONS = {
|
|
frozenColumn: 2,
|
|
addSpacerColumn: true,
|
|
dataItemColumnValueExtractor: function (item, colDef) {
|
|
if (colDef && colDef.field) return resolver(colDef.field, item);
|
|
return null;
|
|
}
|
|
};
|
|
function Grid(container, data, columns, options) {
|
|
this.data = data; this.columns = columns; this.options = options;
|
|
this.onRendered = { subscribe: function () {} };
|
|
}
|
|
Grid.prototype.getColumns = function () { return this.columns; };
|
|
Grid.prototype.getOptions = function () { return this.options; };
|
|
Grid.prototype.getDataLength = function () { return this.data.length; };
|
|
Grid.prototype.getDataItem = function (i) { return this.data[i]; };
|
|
window.Slick = { Grid: Grid };
|
|
setTimeout(function () {
|
|
new window.Slick.Grid("#g", __DATA__, COLUMNS, OPTIONS);
|
|
}, __DELAY__);
|
|
"""
|
|
|
|
COLUMNS = [
|
|
{"id": "pinKey", "field": "HAS_DATA", "output": False},
|
|
{
|
|
"id": "description",
|
|
"field": "DESCRIPTION",
|
|
"outputFormatter": "@description",
|
|
},
|
|
{"id": "chart", "field": "HAS_DATA", "output": False},
|
|
{"id": "units", "name": "units", "field": "UNITS", "display": False},
|
|
{
|
|
"id": "source key",
|
|
"name": "source key",
|
|
"field": "SERIES_ID",
|
|
"display": False,
|
|
},
|
|
{
|
|
"id": "200101",
|
|
"name": "Jan 2001",
|
|
"field": "DATA.200101",
|
|
"rseField": "RSE_DATA.200101",
|
|
"dataFormatter": "@numerical",
|
|
},
|
|
{"id": "spacer", "name": " ", "autoWidth": True},
|
|
]
|
|
DATA = [
|
|
{
|
|
"DESCRIPTION": "United States",
|
|
"LEVEL": 0,
|
|
"CHART_NAME": "United States",
|
|
"SERIES_ID": "ELEC.GEN..M",
|
|
"HAS_DATA": False,
|
|
"PRECISION": 0,
|
|
"DATA": {"200101": "--"},
|
|
},
|
|
{
|
|
"DESCRIPTION": "All fuels",
|
|
"LEVEL": 2,
|
|
"CHART_NAME": "United States : all fuels (utility-scale)",
|
|
"SERIES_ID": "ELEC.GEN.ALL-US-99.M",
|
|
"HAS_DATA": True,
|
|
"PRECISION": 0,
|
|
"UNITS": "thousand megawatthours",
|
|
"DATA": {"200101": 332493.16},
|
|
},
|
|
{
|
|
"DESCRIPTION": "Coal",
|
|
"LEVEL": 3,
|
|
"CHART_NAME": "United States : coal",
|
|
"SERIES_ID": "ELEC.GEN.COW-US-99.M",
|
|
"HAS_DATA": True,
|
|
"PRECISION": 0,
|
|
"UNITS": "thousand megawatthours",
|
|
"DATA": {"200101": 177287.111},
|
|
},
|
|
]
|
|
|
|
ANNUAL_COLUMNS = [
|
|
{"id": "pinKey", "field": "HAS_DATA", "output": False},
|
|
{
|
|
"id": "description",
|
|
"field": "DESCRIPTION",
|
|
"outputFormatter": "@description",
|
|
},
|
|
] + [
|
|
{
|
|
"id": str(year),
|
|
"name": str(year),
|
|
"field": f"DATA.{year}",
|
|
"dataFormatter": "@numerical",
|
|
}
|
|
for year in (2014, 2015, 2009, 2010)
|
|
]
|
|
ANNUAL_DATA = [
|
|
{
|
|
"DESCRIPTION": "Total U.S.",
|
|
"LEVEL": 0,
|
|
"CHART_NAME": "Total U.S.",
|
|
"SERIES_ID": "PET_IMPORTS.WORLD-US-ALL.A",
|
|
"HAS_DATA": True,
|
|
"PRECISION": 0,
|
|
"UNITS": "thousand barrels",
|
|
"DATA": {"2009": 100, "2010": 200, "2014": 239554, "2015": 244173},
|
|
},
|
|
]
|
|
|
|
def run(self, data=None, columns=None, delay=120):
|
|
script = browsers._table_bridge_js(ELECTRICITY)
|
|
bridge = script.replace("<script>", "").replace("</script>", "")
|
|
grid = (
|
|
self.GRID.replace(
|
|
"__DATA__", json.dumps(self.DATA if data is None else data)
|
|
)
|
|
.replace(
|
|
"__COLUMNS__",
|
|
json.dumps(self.COLUMNS if columns is None else columns),
|
|
)
|
|
.replace("__DELAY__", str(delay))
|
|
)
|
|
source = HARNESS.replace("__BRIDGE__", bridge).replace("__GRID__", grid)
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
script_path = Path(tmp) / "harness.js"
|
|
script_path.write_text(source, encoding="utf-8")
|
|
result = subprocess.run( # noqa: S603
|
|
[NODE, str(script_path)],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=30,
|
|
check=True,
|
|
)
|
|
return json.loads(result.stdout)
|
|
|
|
def test_the_rendered_grid_is_published_to_the_shell(self):
|
|
posted = self.run()["posted"]
|
|
assert posted["type"] == "eia:table"
|
|
assert posted["rows"] == [
|
|
{"category": "United States", "Jan 2001": None},
|
|
{
|
|
"category": "United States : all fuels (utility-scale)",
|
|
"Jan 2001": 332493.16,
|
|
},
|
|
{"category": "United States : coal", "Jan 2001": 177287.111},
|
|
]
|
|
|
|
def test_the_same_rows_are_posted_to_the_backend(self):
|
|
result = self.run()
|
|
assert result["sent"]["url"].startswith("/api/v1/eia_table?obb_browser=")
|
|
assert json.loads(result["sent"]["body"]) == result["posted"]["rows"]
|
|
|
|
def test_a_period_value_is_read_through_its_dotted_field_path(self):
|
|
posted = self.run()["posted"]
|
|
assert [row["Jan 2001"] for row in posted["rows"]] == [
|
|
None,
|
|
332493.16,
|
|
177287.111,
|
|
]
|
|
|
|
def test_full_precision_survives_the_grids_own_formatter(self):
|
|
posted = self.run()["posted"]
|
|
assert posted["rows"][2]["Jan 2001"] == 177287.111
|
|
|
|
def test_the_hierarchy_is_the_one_the_grid_renders(self):
|
|
posted = self.run()["posted"]
|
|
assert [row["category"] for row in posted["rows"]] == [
|
|
"United States",
|
|
"United States : all fuels (utility-scale)",
|
|
"United States : coal",
|
|
]
|
|
|
|
def test_only_the_columns_on_screen_are_published(self):
|
|
posted = self.run()["posted"]
|
|
for row in posted["rows"]:
|
|
assert list(row) == ["category", "Jan 2001"]
|
|
|
|
def test_an_annual_table_keeps_its_label_column_first(self):
|
|
result = self.run(data=self.ANNUAL_DATA, columns=self.ANNUAL_COLUMNS)
|
|
rows = json.loads(result["sent"]["body"])
|
|
assert list(rows[0]) == ["category", "2009 ", "2010 ", "2014 ", "2015 "]
|
|
assert rows[0]["category"] == "Total U.S."
|
|
assert rows[0]["2014 "] == 239554
|
|
|
|
def test_icon_button_columns_are_dropped(self):
|
|
columns = [
|
|
{
|
|
"id": "sourcekey",
|
|
"field": "HAS_DATA",
|
|
"output": False,
|
|
"iconClass": "sourcekey",
|
|
},
|
|
{
|
|
"id": "remove",
|
|
"name": "",
|
|
"field": "DESCRIPTION",
|
|
"output": True,
|
|
"iconClass": "remove",
|
|
},
|
|
{
|
|
"id": "description",
|
|
"name": "",
|
|
"field": "DESCRIPTION",
|
|
"outputFormatter": "@description",
|
|
},
|
|
{"id": "map", "field": "HAS_DATA", "output": True, "iconClass": "map"},
|
|
{
|
|
"id": "linechart",
|
|
"field": "HAS_DATA",
|
|
"output": True,
|
|
"iconClass": "chart",
|
|
},
|
|
{
|
|
"id": "200101",
|
|
"name": "Jan 2001",
|
|
"field": "DATA.200101",
|
|
"dataFormatter": "@numerical",
|
|
},
|
|
]
|
|
posted = self.run(columns=columns)["posted"]
|
|
for row in posted["rows"]:
|
|
assert list(row) == ["category", "Jan 2001"]
|
|
|
|
def test_a_row_with_no_data_yields_nulls_not_the_dash(self):
|
|
posted = self.run()["posted"]
|
|
assert posted["rows"][0]["Jan 2001"] is None
|
|
|
|
def test_the_post_does_not_use_keepalive(self):
|
|
assert self.run()["sent"]["keepalive"] is False
|
|
|
|
def test_a_table_over_the_keepalive_limit_is_posted_whole(self):
|
|
columns = (
|
|
self.COLUMNS[:5]
|
|
+ [
|
|
{
|
|
"id": str(200101 + n),
|
|
"name": f"period {n}",
|
|
"field": f"DATA.{200101 + n}",
|
|
"dataFormatter": "@numerical",
|
|
}
|
|
for n in range(305)
|
|
]
|
|
+ [self.COLUMNS[-1]]
|
|
)
|
|
data = [
|
|
{
|
|
"DESCRIPTION": f"Fuel {r}",
|
|
"CHART_NAME": f"United States : fuel {r}",
|
|
"SERIES_ID": f"ELEC.GEN.F{r}-US-99.M",
|
|
"HAS_DATA": True,
|
|
"PRECISION": 0,
|
|
"DATA": {str(200101 + n): 177287.111 + n for n in range(305)},
|
|
}
|
|
for r in range(21)
|
|
]
|
|
sent = self.run(data=data, columns=columns)["sent"]
|
|
assert len(sent["body"]) > 64 * 1024
|
|
rows = json.loads(sent["body"])
|
|
assert len(rows) == 21
|
|
assert rows[0]["period 0"] == 177287.111
|
|
|
|
def test_a_grid_with_no_rows_publishes_nothing(self):
|
|
assert self.run(data=[])["posted"] is None
|
|
|
|
def test_a_grid_built_much_later_is_still_captured(self):
|
|
assert self.run(delay=500)["posted"]["rows"]
|
|
|
|
def test_the_page_is_left_holding_a_working_grid(self):
|
|
source = HARNESS.replace(
|
|
"__BRIDGE__",
|
|
browsers._table_bridge_js(ELECTRICITY)
|
|
.replace("<script>", "")
|
|
.replace("</script>", ""),
|
|
).replace(
|
|
"__GRID__",
|
|
self.GRID.replace("__DATA__", json.dumps(self.DATA))
|
|
.replace("__COLUMNS__", json.dumps(self.COLUMNS))
|
|
.replace("__DELAY__", "120")
|
|
.replace(
|
|
'new window.Slick.Grid("#g", ',
|
|
'var g = new window.Slick.Grid("#g", ',
|
|
)
|
|
.replace(
|
|
"}, 120);",
|
|
" if (!(g instanceof window.Slick.Grid)) throw new Error('broke instanceof');"
|
|
" if (g.getDataLength() !== 3) throw new Error('broke the grid');"
|
|
"}, 120);",
|
|
),
|
|
)
|
|
subprocess.run( # noqa: S603
|
|
[NODE, "-e", source], capture_output=True, text=True, timeout=30, check=True
|
|
)
|
|
|
|
|
|
LIFECYCLE_GRID = """
|
|
var DOC = { contains: function (n) { return n._attached; } };
|
|
function makeNode(attached, visible) {
|
|
var n = {
|
|
_attached: attached, _viewport: true, ownerDocument: DOC,
|
|
querySelector: function (sel) {
|
|
return (n._viewport && sel === '.slick-viewport') ? {} : null;
|
|
},
|
|
getClientRects: function () { return visible ? [{}] : []; },
|
|
offsetParent: visible ? {} : null,
|
|
};
|
|
return n;
|
|
}
|
|
var COLUMNS = [
|
|
{ id: 'description', field: 'DESCRIPTION' },
|
|
{ id: '200101', name: 'Jan 2001', field: 'DATA.200101' },
|
|
];
|
|
var OPTIONS = {
|
|
dataItemColumnValueExtractor: function (item, colDef) {
|
|
var path = colDef.field.split('.'), ret = item;
|
|
for (var i = 0; i < path.length; i++) ret = ret[path[i]];
|
|
return ret;
|
|
},
|
|
};
|
|
function Grid(container, data, columns, options) {
|
|
this.node = container; this.data = data;
|
|
this.columns = columns; this.options = options;
|
|
this.onRendered = { subscribe: function () {} };
|
|
}
|
|
Grid.prototype.getColumns = function () { return this.columns; };
|
|
Grid.prototype.getOptions = function () { return this.options; };
|
|
Grid.prototype.getDataLength = function () { return this.data.length; };
|
|
Grid.prototype.getDataItem = function (i) { return this.data[i]; };
|
|
Grid.prototype.getContainerNode = function () { return this.node; };
|
|
Grid.prototype.destroy = function () { this.node._viewport = false; };
|
|
window.Slick = { Grid: Grid };
|
|
setTimeout(function () {
|
|
var stale = new window.Slick.Grid(makeNode(true, true), __STALE__, COLUMNS, OPTIONS);
|
|
new window.Slick.Grid(makeNode(true, true), __CURRENT__, COLUMNS, OPTIONS);
|
|
__TEARDOWN__
|
|
}, 120);
|
|
"""
|
|
|
|
|
|
def _rows(label, count):
|
|
"""Build ``count`` grid rows carrying ``label`` as their description."""
|
|
return [
|
|
{"DESCRIPTION": f"{label} {n}", "DATA": {"200101": float(n)}}
|
|
for n in range(count)
|
|
]
|
|
|
|
|
|
@pytest.mark.skipif(not Path(NODE).exists(), reason="node is not installed")
|
|
class TestGridLifecycle:
|
|
"""A view the user left must never supply the table ``raw`` answers with.
|
|
|
|
EIA rebuilds the grid per view and calls ``destroy()`` on the old one, which
|
|
only empties the container -- the instance keeps answering
|
|
``getDataLength()`` with the row count of the view it drew. A stale grid is
|
|
routinely larger than the current one, so picking by row count alone hands
|
|
``raw`` the table of a view that is no longer on screen.
|
|
"""
|
|
|
|
def run(self, stale, current, teardown):
|
|
script = browsers._table_bridge_js(ELECTRICITY)
|
|
bridge = script.replace("<script>", "").replace("</script>", "")
|
|
grid = (
|
|
LIFECYCLE_GRID.replace("__STALE__", json.dumps(stale))
|
|
.replace("__CURRENT__", json.dumps(current))
|
|
.replace("__TEARDOWN__", teardown)
|
|
)
|
|
source = HARNESS.replace("__BRIDGE__", bridge).replace("__GRID__", grid)
|
|
result = subprocess.run( # noqa: S603
|
|
[NODE, "-e", source], capture_output=True, text=True, timeout=30, check=True
|
|
)
|
|
return json.loads(result.stdout)
|
|
|
|
def test_a_destroyed_grid_does_not_supply_the_table(self):
|
|
posted = self.run(_rows("STALE", 6), _rows("CURRENT", 2), "stale.destroy();")
|
|
assert [row["category"] for row in posted["posted"]["rows"]] == [
|
|
"CURRENT 0",
|
|
"CURRENT 1",
|
|
]
|
|
|
|
def test_a_detached_grid_does_not_supply_the_table(self):
|
|
posted = self.run(
|
|
_rows("STALE", 6), _rows("CURRENT", 2), "stale.node._attached = false;"
|
|
)
|
|
assert [row["category"] for row in posted["posted"]["rows"]] == [
|
|
"CURRENT 0",
|
|
"CURRENT 1",
|
|
]
|
|
|
|
def test_a_hidden_grid_does_not_supply_the_table(self):
|
|
posted = self.run(
|
|
_rows("STALE", 6),
|
|
_rows("CURRENT", 2),
|
|
"stale.node.offsetParent = null; stale.node.getClientRects = "
|
|
"function () { return []; };",
|
|
)
|
|
assert [row["category"] for row in posted["posted"]["rows"]] == [
|
|
"CURRENT 0",
|
|
"CURRENT 1",
|
|
]
|
|
|
|
def test_the_larger_live_grid_still_wins(self):
|
|
posted = self.run(_rows("STALE", 6), _rows("CURRENT", 2), "")
|
|
assert len(posted["posted"]["rows"]) == 6
|
|
|
|
def test_the_scrape_names_the_view_it_was_taken_from(self):
|
|
sent = self.run(_rows("STALE", 6), _rows("CURRENT", 2), "stale.destroy();")[
|
|
"sent"
|
|
]
|
|
assert "obb_view=electricity%2Fdata%2Fbrowser%2F" in sent["url"]
|
|
|
|
|
|
SYNC_HARNESS = """
|
|
var intervals = [], listeners = {};
|
|
global.window = global;
|
|
var select = {
|
|
tagName: 'SELECT',
|
|
value: '-1',
|
|
options: [
|
|
{ value: '-1', text: 'Select a view' },
|
|
{ value: '0', text: 'Imports of all grades to US' },
|
|
{ value: '8', text: 'Top 10 light crude oil importing refineries in 2013' },
|
|
],
|
|
};
|
|
global.location = {
|
|
pathname: '/api/v1/eia_proxy/petroleum/imports/browser/',
|
|
hash: '#/?vs=PET_IMPORTS.WORLD-US-ALL.A',
|
|
};
|
|
global.document = {
|
|
getElementsByTagName: function (tag) { return tag === 'select' ? [select] : []; },
|
|
};
|
|
global.window.addEventListener = function (name, fn) { listeners[name] = fn; };
|
|
global.setInterval = function (fn) { intervals.push(fn); return intervals.length; };
|
|
var CONFIG = __CONFIG__;
|
|
global.fetch = function () {
|
|
return Promise.resolve({ json: function () { return Promise.resolve(CONFIG); } });
|
|
};
|
|
|
|
__SCRIPT__
|
|
|
|
function tick() { for (var i = 0; i < intervals.length; i++) intervals[i](); }
|
|
setTimeout(function () {
|
|
__DRIVER__
|
|
process.stdout.write(JSON.stringify({ value: select.value }));
|
|
}, 80);
|
|
"""
|
|
|
|
V0_VS = "PET_IMPORTS.WORLD-US-ALL.A"
|
|
V8_VS = "PET_IMPORTS.WORLD-RF_465-O.A~PET_IMPORTS.WORLD-RF_225-O.A"
|
|
CONFIG_WITH_VIEWS = {
|
|
"views": [
|
|
{"id": "0", "json": json.dumps({"vs": V0_VS})},
|
|
{
|
|
"id": "8",
|
|
"json": json.dumps({"columnendpoints": "2", "d": "abc", "vs": V8_VS}),
|
|
},
|
|
]
|
|
}
|
|
CONFIG_NO_VIEWS = {"status": "OK"}
|
|
|
|
|
|
@pytest.mark.skipif(not Path(NODE).exists(), reason="node is not installed")
|
|
class TestFeaturedViewSync:
|
|
"""The Featured views select must mirror the view encoded in the live hash."""
|
|
|
|
def run(self, driver, config=None):
|
|
script = browsers._VIEW_SELECT_SYNC_JS.replace("<script>", "").replace(
|
|
"</script>", ""
|
|
)
|
|
source = (
|
|
SYNC_HARNESS.replace(
|
|
"__CONFIG__",
|
|
json.dumps(CONFIG_WITH_VIEWS if config is None else config),
|
|
)
|
|
.replace("__SCRIPT__", script)
|
|
.replace("__DRIVER__", driver)
|
|
)
|
|
result = subprocess.run( # noqa: S603
|
|
[NODE, "-e", source],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=30,
|
|
check=True,
|
|
)
|
|
return json.loads(result.stdout)
|
|
|
|
def test_the_default_view_is_reflected_on_load(self):
|
|
assert self.run("")["value"] == "0"
|
|
|
|
def test_a_view_in_the_hash_is_reflected(self):
|
|
driver = (
|
|
f"location.hash = '#/?d=xyz&f=a&vs=' + encodeURIComponent('{V8_VS}');"
|
|
"tick();"
|
|
)
|
|
assert self.run(driver)["value"] == "8"
|
|
|
|
def test_matching_ignores_the_other_view_params(self):
|
|
driver = (
|
|
f"location.hash = '#/?vs=' + encodeURIComponent('{V8_VS}') + '&d=DIFF';"
|
|
"tick();"
|
|
)
|
|
assert self.run(driver)["value"] == "8"
|
|
|
|
def test_a_hashchange_event_drives_the_sync(self):
|
|
driver = (
|
|
f"location.hash = '#/?vs=' + encodeURIComponent('{V8_VS}');"
|
|
"listeners['hashchange']();"
|
|
)
|
|
assert self.run(driver)["value"] == "8"
|
|
|
|
def test_an_unmatched_state_clears_to_the_placeholder(self):
|
|
driver = "location.hash = '#/?vs=PET_IMPORTS.NO_SUCH_SERIES.A';tick();"
|
|
assert self.run(driver)["value"] == "-1"
|
|
|
|
def test_a_browser_without_view_config_is_left_untouched(self):
|
|
driver = "select.value = '0';tick();"
|
|
assert self.run(driver, config=CONFIG_NO_VIEWS)["value"] == "0"
|
|
|
|
def test_the_script_is_injected_into_the_classic_browser_page(self):
|
|
page = browsers.rewrite_html(
|
|
"<head></head><body></body>",
|
|
PROXY_PREFIX,
|
|
browser=IMPORTS,
|
|
)
|
|
assert browsers._VIEW_SELECT_SYNC_JS in page
|
|
|
|
|
|
class TestText:
|
|
def test_strips_tags_and_unescapes(self):
|
|
assert browsers._text("<b>A &\n B</b>") == "A & B"
|
|
|
|
|
|
class TestParseMapsPage:
|
|
def test_groups_formats_by_document(self):
|
|
entries = browsers.parse_maps_page(MAPS_HTML)
|
|
assert len(entries) == 4
|
|
shale = entries[0]
|
|
assert shale["tab"] == "Recent maps"
|
|
assert shale["section"] == "Overview & boundaries"
|
|
assert shale["title"] == "Shale plays, Lower 48 States"
|
|
assert shale["date"] == "12/13/2023"
|
|
assert shale["pdf"] == "/maps/images/shale_2023.pdf"
|
|
assert shale["image"] == "/maps/images/shale_2023.jpg"
|
|
|
|
def test_title_without_date_strips_format_suffix(self):
|
|
assert browsers.parse_maps_page(MAPS_HTML)[1] == {
|
|
"tab": "Recent maps",
|
|
"section": "Overview & boundaries",
|
|
"title": "Major tight gas plays",
|
|
"date": "",
|
|
"pdf": "/maps/images/tight_gas.pdf",
|
|
}
|
|
|
|
def test_second_tab_and_image_only(self):
|
|
assert browsers.parse_maps_page(MAPS_HTML)[2] == {
|
|
"tab": "Historical maps",
|
|
"section": "Historical basins",
|
|
"title": "Historic basin map",
|
|
"date": "6/1/2011",
|
|
"image": "/maps/images/old_map.jpg",
|
|
}
|
|
|
|
def test_section_prefix_stripped_from_title(self):
|
|
assert browsers.parse_maps_page(MAPS_HTML)[3] == {
|
|
"tab": "Historical maps",
|
|
"section": "Historical basins",
|
|
"title": "Western Area BOE",
|
|
"date": "",
|
|
"pdf": "/maps/images/field_maps/app_boe.pdf",
|
|
}
|
|
|
|
def test_unsupported_format_skipped(self):
|
|
assert not any(
|
|
entry.get("pdf", "").endswith(".zip")
|
|
for entry in browsers.parse_maps_page(MAPS_HTML)
|
|
)
|
|
|
|
def test_anchor_outside_tabs_skipped(self):
|
|
html = '<a href="/maps/x.pdf" title="Loose (1/1/2020)(pdf)">PDF</a>'
|
|
assert browsers.parse_maps_page(html) == []
|
|
|
|
def test_more_tabs_than_labels_get_placeholder(self):
|
|
html = (
|
|
'<div class="tab-content">'
|
|
'<a href="/maps/a.pdf" title="A (1/1/2020)(pdf)">PDF</a></div>'
|
|
)
|
|
assert browsers.parse_maps_page(html)[0]["tab"] == "Maps 1"
|
|
|
|
|
|
class TestFetchMapsCatalog:
|
|
@pytest.mark.asyncio
|
|
async def test_fetches_parses_and_caches(self, monkeypatch):
|
|
target = f"{browsers._EIA_ORIGIN}/{browsers.MAPS_PAGE}"
|
|
upstream = FakeUpstream({target: (MAPS_HTML.encode(), "text/html")})
|
|
upstream.install(monkeypatch)
|
|
first = await browsers.fetch_maps_catalog()
|
|
second = await browsers.fetch_maps_catalog()
|
|
assert first is second
|
|
assert len(upstream.calls) == 1
|
|
assert first[0]["title"] == "Shale plays, Lower 48 States"
|
|
|
|
|
|
class TestRewriteHtml:
|
|
"""Injected CSS/JS must hide EIA's site chrome without eating its apps."""
|
|
|
|
def test_rewrites_root_paths_and_injects_into_head(self):
|
|
html = '<head></head><body><a href="/coal/">x</a><img src="/img.png"/></body>'
|
|
out = browsers.rewrite_html(html, PROXY_PREFIX)
|
|
assert f'"{PROXY_PREFIX}/coal/"' in out
|
|
assert f'"{PROXY_PREFIX}/img.png"' in out
|
|
assert out.index("<style>") < out.index("</head>")
|
|
|
|
def test_without_head_prepends_injection(self):
|
|
assert browsers.rewrite_html("<body></body>", "/p").startswith("<style>")
|
|
|
|
def test_protocol_relative_untouched(self):
|
|
out = browsers.rewrite_html('<head></head><a href="//cdn/x.js">', "/p")
|
|
assert '"//cdn/x.js"' in out
|
|
|
|
def test_absolute_eia_urls_repointed_at_the_proxy(self):
|
|
out = browsers.rewrite_html(
|
|
'<head></head><a href="https://www.eia.gov/coal/">x</a>', PROXY_PREFIX
|
|
)
|
|
assert "https://www.eia.gov/coal/" not in out
|
|
assert f'"{PROXY_PREFIX}/coal/"' in out
|
|
|
|
def test_chrome_hidden_but_app_headers_survive(self):
|
|
out = browsers.rewrite_html("<head></head>", PROXY_PREFIX)
|
|
hide = out.split("{display:none!important}", 1)[0]
|
|
assert "header" in hide
|
|
assert "#sticker" in hide
|
|
assert ".l-row.header" not in hide
|
|
assert ".sub-navigation" not in hide
|
|
assert ".title-banner" not in hide
|
|
|
|
def test_article_pages_hide_their_own_banner(self):
|
|
out = browsers.rewrite_html("<head></head>", PROXY_PREFIX, article=True)
|
|
assert browsers._ARTICLE_CSS in out
|
|
|
|
def test_browser_pages_do_not_get_the_article_chrome(self):
|
|
out = browsers.rewrite_html(
|
|
"<head></head>", PROXY_PREFIX, browser=INTERNATIONAL
|
|
)
|
|
assert browsers._ARTICLE_CSS not in out
|
|
|
|
def test_dark_root_background_inverts_to_dark(self):
|
|
out = browsers.rewrite_html("<head></head>", PROXY_PREFIX, dark=True)
|
|
assert "background:#fff!important" in out
|
|
assert "#111418" not in out
|
|
|
|
def test_dark_mode_uninverts_country_flags(self):
|
|
out = browsers.rewrite_html("<head></head>", PROXY_PREFIX, dark=True)
|
|
dark = out[out.index("filter:invert(0.92)") :]
|
|
assert dark.index('img[src*=".svg"]') < dark.index('img[src*="flags/"]')
|
|
|
|
def test_light_mode_has_no_filter(self):
|
|
out = browsers.rewrite_html("<head></head>", PROXY_PREFIX, dark=False)
|
|
assert "invert(0.92)" not in out
|
|
|
|
def test_asset_fixer_injected_for_runtime_root_absolute_images(self):
|
|
out = browsers.rewrite_html("<head></head>", PROXY_PREFIX)
|
|
assert browsers._ASSET_FIX_JS in out
|
|
|
|
def test_the_table_bridge_is_bound_to_its_browser(self):
|
|
out = browsers.rewrite_html(
|
|
"<head></head>", PROXY_PREFIX, browser=INTERNATIONAL
|
|
)
|
|
assert f"browser='{INTERNATIONAL}'" in out
|
|
assert "eia:table" in out
|
|
|
|
def test_the_tagger_binds_each_request_to_its_view(self):
|
|
out = browsers.rewrite_html(
|
|
"<head></head>", PROXY_PREFIX, browser=INTERNATIONAL
|
|
)
|
|
assert "XMLHttpRequest.prototype.open" in out
|
|
assert "obb_view" in out
|
|
assert "obb_token" not in out
|
|
|
|
def test_the_tagger_leaves_the_widgets_own_endpoints_alone(self):
|
|
"""A tagged POST to ``/eia_table`` would be proxied to eia.gov."""
|
|
out = browsers.rewrite_html(
|
|
"<head></head>", PROXY_PREFIX, browser=INTERNATIONAL
|
|
)
|
|
assert "var mine=[parts[0]+'/eia_table',parts[0]+'/eia_view'];" in out
|
|
assert (
|
|
"for(var m=0;m<mine.length;m++)if(u.pathname===mine[m])return raw;" in out
|
|
)
|
|
|
|
def test_tagger_omitted_without_a_browser(self):
|
|
out = browsers.rewrite_html("<head></head>", PROXY_PREFIX)
|
|
assert "obb_browser" not in out
|
|
|
|
def test_analytics_and_akamai_scripts_dropped(self):
|
|
html = (
|
|
"<head>"
|
|
'<script src="https://www.googletagmanager.com/gtag/js?id=X"></script>'
|
|
'<script src="/akam/13/abc" defer></script>'
|
|
"</head>"
|
|
)
|
|
out = browsers.rewrite_html(html, PROXY_PREFIX)
|
|
assert "googletagmanager" not in out
|
|
assert "/akam/" not in out
|
|
|
|
@pytest.mark.parametrize(
|
|
("browser", "css"),
|
|
[
|
|
("electricity/data/browser/", "_MAP_LAYOUT_CSS"),
|
|
("coal/data/browser/", "_MAP_LAYOUT_CSS"),
|
|
("naturalgas/ngqs/", "_NGQS_GRID_CSS"),
|
|
("states/", "_STATES_CSS"),
|
|
("international/overview/world", "_INTERNATIONAL_CSS"),
|
|
],
|
|
)
|
|
def test_per_browser_css_only_reaches_its_own_browser(self, browser, css):
|
|
expected = getattr(browsers, css)
|
|
assert expected in browsers.rewrite_html(
|
|
"<head></head>", PROXY_PREFIX, browser=browser
|
|
)
|
|
assert expected not in browsers.rewrite_html(
|
|
"<head></head>", PROXY_PREFIX, browser="outlooks/steo/data/browser/"
|
|
)
|
|
|
|
|
|
class TestRewrittenCache:
|
|
"""Rewritten static assets are cached; the cache is bounded."""
|
|
|
|
def test_second_rewrite_is_served_from_cache(self):
|
|
calls = []
|
|
|
|
def build():
|
|
calls.append(1)
|
|
return "rewritten"
|
|
|
|
key = ("https://t/a.js", "/p")
|
|
assert browsers._rewritten(key, build) == "rewritten"
|
|
assert browsers._rewritten(key, build) == "rewritten"
|
|
assert len(calls) == 1
|
|
|
|
def test_cache_is_bounded(self, monkeypatch):
|
|
monkeypatch.setattr(browsers, "_REWRITE_CACHE_MAX", 2)
|
|
for index in range(4):
|
|
browsers._rewritten((f"https://t/{index}.js", "/p"), lambda: "x")
|
|
assert len(browsers._REWRITE_CACHE) <= 2
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_spa_bundle_rewrites_only_its_own_app_roots(self, monkeypatch):
|
|
script = b'fetch("/states/api/geo.json");var api="/api/analysis";var s="/";'
|
|
FakeUpstream(
|
|
{
|
|
"https://www.eia.gov/states/main.js": (
|
|
script,
|
|
"application/javascript",
|
|
)
|
|
}
|
|
).install(monkeypatch)
|
|
response = await browsers.eia_proxy(
|
|
"states/main.js", make_info("/api/v1/eia_proxy/states/main.js")
|
|
)
|
|
text = response.body.decode()
|
|
assert f'"{PROXY_PREFIX}/states/api/geo.json"' in text
|
|
assert 'var api="/api/analysis"' in text
|
|
assert 'var s="/"' in text
|
|
|
|
|
|
class TestRedirects:
|
|
"""An upstream redirect is mapped back onto the proxy, keeping the markers."""
|
|
|
|
def test_redirect_target_keeps_the_theme(self):
|
|
location = browsers._redirect_target(
|
|
PROXY_PREFIX, f"{browsers._EIA_ORIGIN}/coal/?a=1", True
|
|
)
|
|
assert location.startswith(f"{PROXY_PREFIX}/coal/?")
|
|
assert "a=1" in location
|
|
assert "obb_theme=dark" in location
|
|
|
|
def test_light_theme_is_carried_through(self):
|
|
location = browsers._redirect_target(
|
|
PROXY_PREFIX, f"{browsers._EIA_ORIGIN}/coal/", False
|
|
)
|
|
assert "obb_theme=light" in location
|
|
assert "obb_token" not in location
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_proxy_redirects_the_iframe_to_where_it_landed(self, monkeypatch):
|
|
target = "https://www.eia.gov/from"
|
|
landed = "https://www.eia.gov/to"
|
|
FakeUpstream({target: (b"<head></head>", "text/html", 200, landed)}).install(
|
|
monkeypatch
|
|
)
|
|
response = await browsers.eia_proxy(
|
|
"from", make_info("/api/v1/eia_proxy/from", query="obb_theme=dark")
|
|
)
|
|
assert response.status_code == 307
|
|
assert response.headers["location"].startswith(f"{PROXY_PREFIX}/to?")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_landing_on_the_same_path_is_served_not_redirected(self, monkeypatch):
|
|
target = "https://www.eia.gov/coal/"
|
|
FakeUpstream({target: (b"<head></head>", "text/html", 200, target)}).install(
|
|
monkeypatch
|
|
)
|
|
response = await browsers.eia_proxy(
|
|
"coal/", make_info("/api/v1/eia_proxy/coal/")
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
|
|
class TestProxyPost:
|
|
"""NGQS submits its query as a POST, which must be proxied and recorded."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_post_is_forwarded_with_its_body(self, monkeypatch):
|
|
captured: dict = {}
|
|
|
|
def fake_post(target, body, content_type):
|
|
captured.update(target=target, body=body, content_type=content_type)
|
|
return b'{"TABLE_DATA":[]}', "application/json", 200
|
|
|
|
monkeypatch.setattr(browsers, "_post_sync", fake_post)
|
|
ngqs = browsers.EIA_DATA_BROWSERS["natural_gas_query"]["path"]
|
|
await browsers.eia_proxy(
|
|
"naturalgas/ngqs/data/report/1",
|
|
make_info(
|
|
"/api/v1/eia_proxy/naturalgas/ngqs/data/report/1",
|
|
query=f"obb_browser={ngqs}",
|
|
method="POST",
|
|
body=b"items=1",
|
|
),
|
|
)
|
|
assert captured["body"] == b"items=1"
|
|
assert captured["content_type"] == "application/x-www-form-urlencoded"
|
|
assert captured["target"] == (
|
|
"https://www.eia.gov/naturalgas/ngqs/data/report/1"
|
|
)
|
|
|
|
|
|
class TestFetchUpstream:
|
|
"""Memory, then disk, then network."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_caches_successful_fetch_in_memory(self, monkeypatch):
|
|
upstream = FakeUpstream({"https://t/x": (b"payload", "text/plain")})
|
|
upstream.install(monkeypatch)
|
|
assert await browsers._fetch_upstream("https://t/x") == (
|
|
b"payload",
|
|
"text/plain",
|
|
)
|
|
assert await browsers._fetch_upstream("https://t/x") == (
|
|
b"payload",
|
|
"text/plain",
|
|
)
|
|
assert len(upstream.calls) == 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_non_200_not_cached(self, monkeypatch):
|
|
upstream = FakeUpstream({"https://t/x": (b"gone", "text/plain", 404)})
|
|
upstream.install(monkeypatch)
|
|
await browsers._fetch_upstream("https://t/x")
|
|
await browsers._fetch_upstream("https://t/x")
|
|
assert len(upstream.calls) == 2
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cache_evicts_oldest(self, monkeypatch):
|
|
monkeypatch.setattr(browsers, "_PROXY_CACHE_MAX", 1)
|
|
upstream = FakeUpstream(
|
|
{"https://t/a": (b"a", "text/plain"), "https://t/b": (b"b", "text/plain")}
|
|
)
|
|
upstream.install(monkeypatch)
|
|
await browsers._fetch_upstream("https://t/a")
|
|
await browsers._fetch_upstream("https://t/b")
|
|
assert "https://t/a" not in browsers._PROXY_CACHE
|
|
assert "https://t/b" in browsers._PROXY_CACHE
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_expired_entry_refetched(self, monkeypatch):
|
|
upstream = FakeUpstream({"https://t/x": (b"payload", "text/plain")})
|
|
upstream.install(monkeypatch)
|
|
await browsers._fetch_upstream("https://t/x")
|
|
stamp, body, content_type = browsers._PROXY_CACHE["https://t/x"]
|
|
browsers._PROXY_CACHE["https://t/x"] = (
|
|
stamp - browsers._PROXY_CACHE_TTL - 1,
|
|
body,
|
|
content_type,
|
|
)
|
|
await browsers._fetch_upstream("https://t/x")
|
|
assert len(upstream.calls) == 2
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_static_asset_persisted_to_disk_and_served_from_it(self, monkeypatch):
|
|
target = "https://t/app.js"
|
|
upstream = FakeUpstream({target: (b"code", "application/javascript")})
|
|
upstream.install(monkeypatch)
|
|
await browsers._fetch_upstream(target)
|
|
body_path, type_path = browsers._cache_paths(target)
|
|
assert body_path.read_bytes() == b"code"
|
|
assert type_path.read_text() == "application/javascript"
|
|
|
|
browsers._PROXY_CACHE.clear()
|
|
assert await browsers._fetch_upstream(target) == (
|
|
b"code",
|
|
"application/javascript",
|
|
)
|
|
assert len(upstream.calls) == 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_html_is_never_written_to_disk(self, monkeypatch):
|
|
target = "https://t/page.html"
|
|
FakeUpstream({target: (b"<html>", "text/html")}).install(monkeypatch)
|
|
await browsers._fetch_upstream(target)
|
|
assert not browsers._cache_paths(target)[0].exists()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_redirect_target_remembered(self, monkeypatch):
|
|
target = "https://t/from"
|
|
landed = "https://t/to"
|
|
FakeUpstream({target: (b"x", "text/html", 200, landed)}).install(monkeypatch)
|
|
await browsers._fetch_upstream(target)
|
|
assert browsers._REDIRECTS[target] == landed
|
|
|
|
|
|
class TestEiaProxy:
|
|
"""The proxy rewrites what it must and passes the rest through untouched."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_html_rewritten_and_query_forwarded(self, monkeypatch):
|
|
upstream = FakeUpstream(
|
|
{
|
|
"https://www.eia.gov/coal/?a=1": (
|
|
b'<head></head><a href="/x">x</a>',
|
|
"text/html",
|
|
)
|
|
}
|
|
)
|
|
upstream.install(monkeypatch)
|
|
response = await browsers.eia_proxy(
|
|
"coal/", make_info("/api/v1/eia_proxy/coal/", query="a=1")
|
|
)
|
|
assert f'"{PROXY_PREFIX}/x"' in response.body.decode()
|
|
assert upstream.calls == ["https://www.eia.gov/coal/?a=1"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_html_is_never_cached_by_the_browser(self, monkeypatch):
|
|
FakeUpstream(
|
|
{"https://www.eia.gov/coal/": (b"<head></head>", "text/html")}
|
|
).install(monkeypatch)
|
|
response = await browsers.eia_proxy(
|
|
"coal/", make_info("/api/v1/eia_proxy/coal/")
|
|
)
|
|
assert response.headers["Cache-Control"] == "no-store"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_widget_markers_stripped_before_upstream(self, monkeypatch):
|
|
upstream = FakeUpstream(
|
|
{"https://www.eia.gov/coal/?a=1": (b"<head></head>", "text/html")}
|
|
)
|
|
upstream.install(monkeypatch)
|
|
await browsers.eia_proxy(
|
|
"coal/",
|
|
make_info(
|
|
"/api/v1/eia_proxy/coal/",
|
|
query="a=1&obb_theme=dark",
|
|
),
|
|
)
|
|
assert "obb_" not in upstream.calls[0]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_css_urls_rewritten(self, monkeypatch):
|
|
FakeUpstream(
|
|
{
|
|
"https://www.eia.gov/s.css": (
|
|
b"body{background:url(/img.png)}",
|
|
"text/css",
|
|
)
|
|
}
|
|
).install(monkeypatch)
|
|
response = await browsers.eia_proxy(
|
|
"s.css", make_info("/api/v1/eia_proxy/s.css")
|
|
)
|
|
assert f"url({PROXY_PREFIX}/img.png)".encode() in response.body
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_js_spa_roots_rewritten_but_bare_slash_untouched(self, monkeypatch):
|
|
script = b'fetch("/states/api/x");var sep="/";var g="/global/a.js";'
|
|
FakeUpstream(
|
|
{"https://www.eia.gov/app.js": (script, "application/javascript")}
|
|
).install(monkeypatch)
|
|
response = await browsers.eia_proxy(
|
|
"app.js", make_info("/api/v1/eia_proxy/app.js")
|
|
)
|
|
text = response.body.decode()
|
|
assert f'"{PROXY_PREFIX}/states/api/x"' in text
|
|
assert f'"{PROXY_PREFIX}/global/a.js"' in text
|
|
assert 'sep="/"' in text
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_binary_passthrough(self, monkeypatch):
|
|
FakeUpstream(
|
|
{"https://www.eia.gov/m.pdf": (b"%PDF-1.6", "application/pdf")}
|
|
).install(monkeypatch)
|
|
response = await browsers.eia_proxy(
|
|
"m.pdf", make_info("/api/v1/eia_proxy/m.pdf")
|
|
)
|
|
assert response.body == b"%PDF-1.6"
|
|
assert response.media_type == "application/pdf"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_upstream_error_returns_502(self, monkeypatch):
|
|
FakeUpstream({"https://www.eia.gov/x": RuntimeError("boom")}).install(
|
|
monkeypatch
|
|
)
|
|
response = await browsers.eia_proxy("x", make_info("/api/v1/eia_proxy/x"))
|
|
assert response.status_code == 502
|
|
assert response.body == b"Upstream request failed."
|
|
assert b"boom" not in response.body
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_deep_linked_spa_route_still_gets_the_bridge(self, monkeypatch):
|
|
"""A deep-linked sub-route is not the root path, but still needs the bridge."""
|
|
target = "https://www.eia.gov/international/overview/USA"
|
|
FakeUpstream({target: (b"<head></head>", "text/html")}).install(monkeypatch)
|
|
response = await browsers.eia_proxy(
|
|
"international/overview/USA",
|
|
make_info("/api/v1/eia_proxy/international/overview/USA"),
|
|
)
|
|
page = response.body.decode()
|
|
assert f"browser='{INTERNATIONAL}'" in page
|
|
assert "/eia_table" in page
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_article_pages_get_no_tagger(self, monkeypatch):
|
|
target = "https://www.eia.gov/todayinenergy/detail.php?id=1"
|
|
FakeUpstream({target: (b"<head></head>", "text/html")}).install(monkeypatch)
|
|
response = await browsers.eia_proxy(
|
|
"todayinenergy/detail.php",
|
|
make_info("/api/v1/eia_proxy/todayinenergy/detail.php", query="id=1"),
|
|
)
|
|
assert "obb_browser" not in response.body.decode()
|
|
|
|
|
|
class TestRenderBrowser:
|
|
"""Each browser is its own widget, carrying EIA's own description."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_site_payload(self):
|
|
info = make_info("/api/v1/coal_browser")
|
|
response = await browsers.render_browser("coal", "light", False, info)
|
|
text = response.body.decode()
|
|
assert '"mode": "site"' in text
|
|
assert '"theme": "light"' in text
|
|
assert "eia_proxy/coal/data/browser/" in text
|
|
assert browsers.EIA_DATA_BROWSERS["coal"]["description"][:40] in text
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unknown_browser_falls_back_to_electricity(self):
|
|
info = make_info("/api/v1/bogus_browser")
|
|
response = await browsers.render_browser("bogus", "dark", False, info)
|
|
text = response.body.decode()
|
|
assert "electricity/data/browser/" in text
|
|
assert '"theme": "dark"' in text
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_site_payload_without_request_info(self):
|
|
response = await browsers.render_browser("aeo", "dark", False, None)
|
|
text = response.body.decode()
|
|
assert '"mode": "site"' in text
|
|
assert '"endpoint": "http://127.0.0.1:6900/api/v1/eia/aeo_browser"' in text
|
|
assert "eia_proxy/outlooks/aeo/data/browser/" in text
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_endpoint_without_request_info_returns_html_text(self):
|
|
endpoint = browsers._browser_endpoint("maps")
|
|
out = await endpoint(theme="dark", raw=False)
|
|
assert isinstance(out, str)
|
|
assert "<!DOCTYPE html>" in out
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_petroleum_imports_lands_on_its_default_view(self):
|
|
info = make_info("/api/v1/petroleum_imports_browser")
|
|
response = await browsers.render_browser(
|
|
"petroleum_imports", "dark", False, info
|
|
)
|
|
assert "%23/?vs=PET_IMPORTS.WORLD-US-ALL.A" in response.body.decode() or (
|
|
"#/?vs=PET_IMPORTS.WORLD-US-ALL.A" in response.body.decode()
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_maps_mode_embeds_catalog_and_escapes_html(self, monkeypatch):
|
|
async def fake_catalog():
|
|
return [{"tab": "R", "section": "S", "title": "T<'&", "pdf": "/maps/x.pdf"}]
|
|
|
|
monkeypatch.setattr(browsers, "fetch_maps_catalog", fake_catalog)
|
|
info = make_info("/api/v1/maps_browser")
|
|
response = await browsers.render_browser("maps", "dark", False, info)
|
|
text = response.body.decode()
|
|
assert '"mode": "maps"' in text
|
|
assert "\\u003c" in text
|
|
assert "\\u0026" in text
|
|
assert "/maps/x.pdf" in text
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_raw_serves_the_table_the_page_published(self, monkeypatch):
|
|
rows = [{"category": "Production", "2023": 1.5}]
|
|
browsers.set_table_rows(INTERNATIONAL, rows, 2000)
|
|
|
|
def unused(_target):
|
|
raise AssertionError("raw must not reach upstream")
|
|
|
|
monkeypatch.setattr(browsers, "_fetch_sync", unused)
|
|
info = make_info("/api/v1/international_browser")
|
|
response = await browsers.render_browser("international", "dark", True, info)
|
|
assert json.loads(response.body) == rows
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_raw_replays_the_view_the_page_is_showing(self, monkeypatch):
|
|
"""Grids whose API the page cannot reach are served from their payload."""
|
|
spec = browsers.EIA_DATA_BROWSERS["electricity"]
|
|
target = "https://www.eia.gov/electricity/x?method=getAggregateData"
|
|
payload = json.dumps(
|
|
{
|
|
"TABLEDATA": {
|
|
"ROWS": [
|
|
{
|
|
"DESCRIPTION": "United States",
|
|
"LEVEL": 0,
|
|
"UNITS": "thousand megawatthours",
|
|
"SERIES_ID": "ELEC.GEN.ALL-US-99.M",
|
|
"DATA": {"200101": 332493.16},
|
|
}
|
|
],
|
|
"DATACOLUMNS": [200101],
|
|
}
|
|
}
|
|
).encode()
|
|
FakeUpstream({target: (payload, "application/json")}).install(monkeypatch)
|
|
browsers.set_current_view(spec["path"], "electricity/data/browser/#/topic/0")
|
|
browsers.set_data_target(
|
|
spec["path"],
|
|
"electricity/data/browser/#/topic/0",
|
|
{"url": target, "method": "GET"},
|
|
)
|
|
info = make_info("/api/v1/electricity_browser")
|
|
response = await browsers.render_browser("electricity", "dark", True, info)
|
|
rows = json.loads(response.body)
|
|
assert rows[0]["category"] == "United States"
|
|
assert rows[0]["2001-01"] == 332493.16
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_raw_for_maps_returns_the_catalog(self, monkeypatch):
|
|
async def fake_catalog():
|
|
return [{"tab": "R", "section": "S", "title": "T", "pdf": "/x.pdf"}]
|
|
|
|
monkeypatch.setattr(browsers, "fetch_maps_catalog", fake_catalog)
|
|
info = make_info("/api/v1/maps_browser")
|
|
response = await browsers.render_browser("maps", "dark", True, info)
|
|
assert json.loads(response.body)[0]["title"] == "T"
|
|
|
|
|
|
class TestWidgetStateRestore:
|
|
"""Each user's iframe navigation is stashed and restored across the raw toggle."""
|
|
|
|
MER = browsers.EIA_DATA_BROWSERS["total_energy"]["path"]
|
|
|
|
def test_state_stores_isolate_users(self):
|
|
browsers.set_current_view(self.MER, "mer#/topic/1", 1000, "alice")
|
|
browsers.set_current_view(self.MER, "mer#/topic/2", 1000, "bob")
|
|
assert browsers.get_current_view(self.MER, "alice") == "mer#/topic/1"
|
|
assert browsers.get_current_view(self.MER, "bob") == "mer#/topic/2"
|
|
assert browsers.get_current_view(self.MER) == ""
|
|
browsers.set_table_rows(self.MER, [{"a": 1}], 1000, "alice")
|
|
assert browsers.get_table_rows(self.MER, "alice") == [{"a": 1}]
|
|
assert browsers.get_table_rows(self.MER, "bob") is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_the_iframe_restores_the_users_last_view(self):
|
|
view = f"{self.MER}?obb_theme=dark&obb_user=u123#/topic/5?agg=2,0,1"
|
|
browsers.set_current_view(self.MER, view, 2000, "u123")
|
|
info = make_info("/api/v1/total_energy_browser", user="u123")
|
|
response = await browsers.render_browser("total_energy", "dark", False, info)
|
|
text = response.body.decode()
|
|
assert "topic/5" in text
|
|
assert "obb_user=u123" in text
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_the_default_view_is_used_without_a_stash(self):
|
|
info = make_info("/api/v1/total_energy_browser", user="u123")
|
|
response = await browsers.render_browser("total_energy", "dark", False, info)
|
|
text = response.body.decode()
|
|
assert f"eia_proxy/{self.MER}" in text
|
|
assert "topic/" not in text
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_users_view_never_leaks_into_another_users_iframe(self):
|
|
browsers.set_current_view(self.MER, f"{self.MER}#/topic/9", 2000, "alice")
|
|
info = make_info("/api/v1/total_energy_browser", user="bob")
|
|
response = await browsers.render_browser("total_energy", "dark", False, info)
|
|
assert "topic/9" not in response.body.decode()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_the_beacon_keys_the_view_by_user(self):
|
|
await browsers.eia_view(
|
|
make_info(
|
|
"/api/v1/eia_view",
|
|
query=f"obb_browser={self.MER}&obb_user=carol&obb_view=mer%23%2Ftopic%2F7&obb_seq=3000",
|
|
)
|
|
)
|
|
assert browsers.get_current_view(self.MER, "carol") == "mer#/topic/7"
|
|
assert browsers.get_current_view(self.MER, "dave") == ""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_raw_serves_each_user_their_own_table(self, monkeypatch):
|
|
browsers.set_current_view(
|
|
self.MER, f"{self.MER}?tbl=T09.05#/?f=M", 2000, "alice"
|
|
)
|
|
browsers.set_current_view(self.MER, f"{self.MER}?tbl=T01.01#/?f=M", 2000, "bob")
|
|
|
|
def table(desc):
|
|
return (
|
|
'<script>var sampleData = {"UNITS":"u","ROWS":'
|
|
'[{"MSN":"X","DESCRIPTION":"'
|
|
+ desc
|
|
+ '","DATA":{"2024":"1"}}]};</script>'
|
|
).encode()
|
|
|
|
FakeUpstream(
|
|
{
|
|
f"{browsers._EIA_ORIGIN}/{self.MER}?tbl=T09.05": (
|
|
table("Refiner Prices"),
|
|
"text/html",
|
|
),
|
|
f"{browsers._EIA_ORIGIN}/{self.MER}?tbl=T01.01": (
|
|
table("Primary Energy"),
|
|
"text/html",
|
|
),
|
|
}
|
|
).install(monkeypatch)
|
|
alice = await browsers.render_browser(
|
|
"total_energy", "dark", True, make_info("/api/v1/x", user="alice")
|
|
)
|
|
bob = await browsers.render_browser(
|
|
"total_energy", "dark", True, make_info("/api/v1/x", user="bob")
|
|
)
|
|
assert json.loads(alice.body)[0]["category"] == "Refiner Prices"
|
|
assert json.loads(bob.body)[0]["category"] == "Primary Energy"
|
|
|
|
def test_the_bridges_carry_the_user_key(self):
|
|
xhr = browsers._xhr_tag_js(self.MER, "u9")
|
|
tbl = browsers._table_bridge_js(self.MER, "u9")
|
|
assert "user='u9'" in xhr and "obb_user" in xhr
|
|
assert "user='u9'" in tbl and "obb_user" in tbl
|
|
assert "obb_user" in browsers._NAV_GUARD_JS
|
|
|
|
def test_the_view_bridge_reports_the_query_string(self):
|
|
assert "search:location.search" in browsers._view_bridge_js()
|
|
|
|
def test_a_view_query_keeps_page_params_and_drops_widget_markers(self):
|
|
view = f"{self.MER}?obb_theme=dark&obb_user=X&tbl=T09.05#/?f=M"
|
|
assert browsers._view_query(view) == "tbl=T09.05"
|
|
assert browsers._view_query("") == ""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_the_host_carries_the_user_and_path_for_state_reporting(self):
|
|
info = make_info("/api/v1/total_energy_browser", user="HASH9")
|
|
response = await browsers.render_browser("total_energy", "dark", False, info)
|
|
text = response.body.decode()
|
|
assert '"user": "HASH9"' in text
|
|
assert '"path": "totalenergy/data/browser/"' in text
|
|
assert "reportView" in text and "restoredSrc" in text
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_home_is_the_default_view_not_the_restored_one(self):
|
|
browsers.set_current_view(
|
|
self.MER, f"{self.MER}?tbl=T09.05#/?f=M&start=201506", 2000, "u123"
|
|
)
|
|
info = make_info("/api/v1/total_energy_browser", user="u123")
|
|
text = (
|
|
await browsers.render_browser("total_energy", "dark", False, info)
|
|
).body.decode()
|
|
marker = '<script id="browser-data" type="application/json">'
|
|
blob = text.split(marker, 1)[1].split("</script>", 1)[0]
|
|
data = json.loads(
|
|
blob.replace("\\u003c", "<").replace("\\u003e", ">").replace("\\u0026", "&")
|
|
)
|
|
assert "tbl=T09.05" in data["src"]
|
|
assert "tbl=T09.05" not in data["home"] and "start=" not in data["home"]
|
|
|
|
|
|
class TestRouterRegistration:
|
|
"""Every browser is registered as its own Workspace widget."""
|
|
|
|
def routes(self):
|
|
return {route.path: route for route in browsers.router._api_router.routes}
|
|
|
|
def test_proxy_and_table_sink_are_registered_and_hidden(self):
|
|
routes = self.routes()
|
|
assert "/eia_proxy/{path:path}" in routes
|
|
assert "/eia_table" in routes
|
|
assert routes["/eia_proxy/{path:path}"].include_in_schema is False
|
|
assert routes["/eia_table"].include_in_schema is False
|
|
|
|
def test_the_view_beacon_is_registered_and_hidden(self):
|
|
routes = self.routes()
|
|
assert "/eia_view" in routes
|
|
assert routes["/eia_view"].include_in_schema is False
|
|
|
|
def test_one_widget_route_per_browser(self):
|
|
routes = self.routes()
|
|
for browser in browsers.EIA_DATA_BROWSERS:
|
|
assert browsers.widget_route(browser) in routes
|
|
|
|
@pytest.mark.parametrize("browser", sorted(browsers.EIA_DATA_BROWSERS))
|
|
def test_widget_config_metadata(self, browser):
|
|
route = self.routes()[browsers.widget_route(browser)]
|
|
config = route.openapi_extra["widget_config"]
|
|
spec = browsers.EIA_DATA_BROWSERS[browser]
|
|
assert config["name"] == f"EIA {spec['label']} Browser"
|
|
assert config["description"] == spec["description"]
|
|
assert config["category"] == "EIA"
|
|
assert config["subCategory"] == "Data Browsers"
|
|
assert config["source"] == ["EIA"]
|
|
assert config["widgetId"] == browsers.widget_id(browser)
|
|
assert config["raw"] is True
|
|
params = {p["paramName"]: p for p in config["params"]}
|
|
assert params["theme"]["show"] is False
|
|
assert params["raw"]["show"] is False
|
|
|
|
@pytest.mark.parametrize("browser", sorted(browsers.EIA_DATA_BROWSERS))
|
|
def test_the_browser_widget_never_refetches_itself(self, browser):
|
|
config = self.routes()[browsers.widget_route(browser)].openapi_extra[
|
|
"widget_config"
|
|
]
|
|
assert config["refetchInterval"] is False
|
|
assert config["staleTime"] == 1000
|
|
|
|
def test_iframe_protocol_does_not_expose_browser_as_a_param(self):
|
|
template = browsers._TEMPLATE.read_text(encoding="utf-8")
|
|
assert 'paramName: "browser"' not in template
|
|
assert "params: []" in template
|
|
|
|
def test_frame_load_event_reveals_the_browser(self):
|
|
template = browsers._TEMPLATE.read_text(encoding="utf-8")
|
|
assert 'frame.addEventListener("load", ready);' in template
|
|
|
|
|
|
class TestViewTracking:
|
|
"""``raw`` must answer for the view on screen, including after click-throughs."""
|
|
|
|
OVERVIEW = "international/overview/world"
|
|
COUNTRY = "international/data/country/USA/infographic/total-production"
|
|
|
|
def test_raw_follows_the_current_view(self):
|
|
browsers.set_current_view(INTERNATIONAL, self.OVERVIEW)
|
|
browsers.set_data_target(
|
|
INTERNATIONAL, self.OVERVIEW, {"url": "OVERVIEW", "method": "GET"}
|
|
)
|
|
assert browsers.get_data_target(INTERNATIONAL)["url"] == "OVERVIEW"
|
|
|
|
browsers.set_current_view(INTERNATIONAL, self.COUNTRY)
|
|
browsers.set_data_target(
|
|
INTERNATIONAL, self.COUNTRY, {"url": "COUNTRY", "method": "GET"}
|
|
)
|
|
assert browsers.get_data_target(INTERNATIONAL)["url"] == "COUNTRY"
|
|
|
|
def test_returning_to_a_cached_view_resolves_that_view(self):
|
|
for view, url in ((self.OVERVIEW, "OVERVIEW"), (self.COUNTRY, "COUNTRY")):
|
|
browsers.set_current_view(INTERNATIONAL, view)
|
|
browsers.set_data_target(INTERNATIONAL, view, {"url": url, "method": "GET"})
|
|
browsers.set_current_view(INTERNATIONAL, self.OVERVIEW)
|
|
assert browsers.get_data_target(INTERNATIONAL)["url"] == "OVERVIEW"
|
|
|
|
def test_unseen_view_falls_back_to_the_last_request(self):
|
|
browsers.set_data_target(
|
|
INTERNATIONAL, self.OVERVIEW, {"url": "OVERVIEW", "method": "GET"}
|
|
)
|
|
browsers.set_current_view(INTERNATIONAL, "international/rankings/world")
|
|
assert browsers.get_data_target(INTERNATIONAL)["url"] == "OVERVIEW"
|
|
|
|
def test_tokenless_spa_requests_resolve_for_a_known_user(self):
|
|
browsers.set_current_view(INTERNATIONAL, self.OVERVIEW)
|
|
browsers.set_data_target(
|
|
INTERNATIONAL, self.OVERVIEW, {"url": "OVERVIEW", "method": "GET"}
|
|
)
|
|
assert browsers.get_data_target(INTERNATIONAL)["url"] == "OVERVIEW"
|
|
|
|
def test_nothing_recorded_yields_no_target(self):
|
|
assert browsers.get_data_target(INTERNATIONAL) is None
|
|
|
|
def test_user_falls_back_to_anon_view_data(self):
|
|
browsers.set_current_view(INTERNATIONAL, self.OVERVIEW, 1000, "alice")
|
|
browsers.set_data_target(
|
|
INTERNATIONAL, self.OVERVIEW, {"url": "ANON", "method": "GET"}, 1000, ""
|
|
)
|
|
assert browsers.get_data_target(INTERNATIONAL, "alice")["url"] == "ANON"
|
|
|
|
def test_user_falls_back_to_anon_last_data(self):
|
|
browsers.set_current_view(INTERNATIONAL, "international/unseen", 1000, "alice")
|
|
browsers.set_data_target(
|
|
INTERNATIONAL,
|
|
self.OVERVIEW,
|
|
{"url": "LAST_ANON", "method": "GET"},
|
|
1000,
|
|
"",
|
|
)
|
|
assert browsers.get_data_target(INTERNATIONAL, "alice")["url"] == "LAST_ANON"
|
|
|
|
|
|
class TestEiaViewBeacon:
|
|
"""The iframe reports each navigation so the server knows the current view."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_beacon_records_the_view(self):
|
|
info = make_info(
|
|
"/api/v1/eia_view",
|
|
query=f"obb_browser={INTERNATIONAL}&obb_view=international%2Fdata",
|
|
method="POST",
|
|
)
|
|
response = await browsers.eia_view(info)
|
|
assert response.status_code == 204
|
|
assert browsers.get_current_view(INTERNATIONAL) == "international/data"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_beacon_without_a_known_browser_records_nothing(self):
|
|
await browsers.eia_view(
|
|
make_info("/api/v1/eia_view", query="obb_browser=bogus&obb_view=x")
|
|
)
|
|
assert not browsers._CURRENT_VIEW
|
|
|
|
|
|
class TestWidgetStateBeacon:
|
|
@pytest.mark.asyncio
|
|
async def test_state_beacon_records_view_and_widget_state(self):
|
|
info = make_info(
|
|
"/api/v1/eia_state",
|
|
query=f"obb_browser={INTERNATIONAL}&obb_view=international%2Fdata&obb_seq=7&obb_user=u1",
|
|
method="POST",
|
|
body=b'{"selected":"oil"}',
|
|
user="u1",
|
|
)
|
|
response = await browsers.eia_state(info)
|
|
assert response.status_code == 204
|
|
assert browsers.get_current_view(INTERNATIONAL, "u1") == "international/data"
|
|
assert browsers.get_widget_state(INTERNATIONAL, "u1") == {"selected": "oil"}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_state_beacon_invalid_json_defaults_to_empty_dict(self):
|
|
info = make_info(
|
|
"/api/v1/eia_state",
|
|
query=f"obb_browser={INTERNATIONAL}&obb_view=international%2Fdata&obb_seq=8&obb_user=u2",
|
|
method="POST",
|
|
body=b"{",
|
|
user="u2",
|
|
)
|
|
response = await browsers.eia_state(info)
|
|
assert response.status_code == 204
|
|
assert browsers.get_widget_state(INTERNATIONAL, "u2") == {}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_state_beacon_unknown_browser_is_ignored(self):
|
|
response = await browsers.eia_state(
|
|
make_info(
|
|
"/api/v1/eia_state",
|
|
query="obb_browser=bogus",
|
|
method="POST",
|
|
body=b"{}",
|
|
)
|
|
)
|
|
assert response.status_code == 204
|
|
assert not browsers._WIDGET_STATE
|
|
|
|
def test_widget_state_uses_freshest_any_user_when_user_unknown(self):
|
|
browsers.set_widget_state(INTERNATIONAL, {"value": "older"}, 1, "alice")
|
|
browsers.set_widget_state(INTERNATIONAL, {"value": "newer"}, 2, "bob")
|
|
assert browsers.get_widget_state(INTERNATIONAL, "carol") == {"value": "newer"}
|
|
|
|
def test_widget_state_user_falls_back_to_anon_bucket(self):
|
|
browsers.set_widget_state(INTERNATIONAL, {"value": "anon"}, 1, "")
|
|
assert browsers.get_widget_state(INTERNATIONAL, "alice") == {"value": "anon"}
|
|
|
|
|
|
class TestMcpStateEndpoint:
|
|
@pytest.mark.asyncio
|
|
async def test_mcp_state_known_browser_uses_raw_table(self, monkeypatch):
|
|
async def fake_raw(browser, spec, user):
|
|
assert browser == "international"
|
|
assert spec == browsers.EIA_DATA_BROWSERS["international"]
|
|
assert user == "u1"
|
|
return [{"x": 1}]
|
|
|
|
monkeypatch.setattr(browsers, "raw_table", fake_raw)
|
|
response = await browsers.eia_mcp_state("international", "u1")
|
|
assert response.status_code == 200
|
|
assert json.loads(response.body) == [{"x": 1}]
|
|
assert response.headers["cache-control"] == "no-store"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_mcp_state_unknown_browser_returns_empty(self):
|
|
response = await browsers.eia_mcp_state("bogus", "u1")
|
|
assert response.status_code == 200
|
|
assert json.loads(response.body) == []
|
|
|
|
|
|
class TestRawTable:
|
|
"""``raw`` replays the recorded request, falling back to the default view."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_total_energy_falls_back_to_the_inline_table(self, monkeypatch):
|
|
spec = browsers.EIA_DATA_BROWSERS["total_energy"]
|
|
html = (
|
|
'<script>var sampleData = {"UNITS":"u","ROWS":'
|
|
'[{"MSN":"TETCB","DESCRIPTION":"Total","DATA":{"2024":"1"}}]};</script>'
|
|
)
|
|
FakeUpstream(
|
|
{f"{browsers._EIA_ORIGIN}/{spec['path']}": (html.encode(), "text/html")}
|
|
).install(monkeypatch)
|
|
rows = await browsers.raw_table("total_energy", spec)
|
|
assert rows == [
|
|
{"category": "Total", "units": "u", "source_key": "TETCB", "2024 ": 1.0}
|
|
]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_falls_back_to_the_browser_default_view(self, monkeypatch):
|
|
spec = browsers.EIA_DATA_BROWSERS["electricity"]
|
|
captured: dict = {}
|
|
|
|
def fake_fetch(target):
|
|
captured["target"] = target
|
|
return b'{"TABLEDATA":{"ROWS":[]}}', "application/json", 200, target
|
|
|
|
monkeypatch.setattr(browsers, "_fetch_sync", fake_fetch)
|
|
await browsers.raw_table("electricity", spec)
|
|
assert browsers._TABLE_ENDPOINT in captured["target"]
|
|
assert f"method={browsers._TABLE_METHOD}" in captured["target"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_browser_without_a_default_view_yields_no_rows(self, monkeypatch):
|
|
spec = dict(browsers.EIA_DATA_BROWSERS["electricity"], hash="")
|
|
assert await browsers.raw_table("electricity", spec) == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_recorded_post_is_replayed_as_a_post(self, monkeypatch):
|
|
spec = browsers.EIA_DATA_BROWSERS["natural_gas_query"]
|
|
captured: dict = {}
|
|
|
|
def fake_post(target, body, content_type):
|
|
captured.update(target=target, body=body, content_type=content_type)
|
|
return b'{"TABLE_DATA":[]}', "application/json", 200
|
|
|
|
monkeypatch.setattr(browsers, "_post_sync", fake_post)
|
|
browsers.set_current_view(spec["path"], "ngqs")
|
|
browsers.set_data_target(
|
|
spec["path"],
|
|
"ngqs",
|
|
{
|
|
"url": "https://www.eia.gov/naturalgas/ngqs/data/report",
|
|
"method": "POST",
|
|
"body": b"payload",
|
|
"content_type": "application/json",
|
|
},
|
|
)
|
|
assert await browsers.raw_table("natural_gas_query", spec) == []
|
|
assert captured["body"] == b"payload"
|
|
assert captured["content_type"] == "application/json"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_non_json_response_yields_no_rows(self, monkeypatch):
|
|
spec = browsers.EIA_DATA_BROWSERS["electricity"]
|
|
target = "https://www.eia.gov/x"
|
|
FakeUpstream({target: (b"<html>", "text/html")}).install(monkeypatch)
|
|
browsers.set_current_view(spec["path"], "v")
|
|
browsers.set_data_target(spec["path"], "v", {"url": target, "method": "GET"})
|
|
assert await browsers.raw_table("electricity", spec) == []
|
|
|
|
|
|
INTL_FEEDS = {
|
|
"countries": [{"iso": "USA", "name": "United States"}],
|
|
"political_groups": [{"iso": "OPEC", "name": "OPEC"}],
|
|
"units": [{"code": "QBTU", "short_name": "quad Btu"}],
|
|
"products": [
|
|
{"id": 44, "name": "primary energy", "parent": None},
|
|
{"id": 80, "name": "fuel ethanol", "parent": 79},
|
|
],
|
|
"activities": [{"id": 1, "name": "production"}],
|
|
}
|
|
|
|
|
|
def intl_upstream() -> FakeUpstream:
|
|
"""Serve every International label feed the pivot needs."""
|
|
origin = browsers._EIA_ORIGIN
|
|
return FakeUpstream(
|
|
{
|
|
f"{origin}/international/api/{feed}/data": (
|
|
json.dumps({"data": records}).encode(),
|
|
"application/json",
|
|
)
|
|
for feed, records in INTL_FEEDS.items()
|
|
}
|
|
)
|
|
|
|
|
|
class TestTotalEnergyState:
|
|
"""The MER raw reflects the full tracked state: table, frequency and range."""
|
|
|
|
MER = browsers.EIA_DATA_BROWSERS["total_energy"]["path"]
|
|
SPEC = browsers.EIA_DATA_BROWSERS["total_energy"]
|
|
PAGE = (
|
|
'<script>var sampleData = {"UNITS":"u","ROWS":[{"MSN":"X",'
|
|
'"DESCRIPTION":"Series","DATA":{"2018":"10","2019":"11",'
|
|
'"201812":"1.2","201901":"1.3","202604":"9.9"}}]};</script>'
|
|
)
|
|
|
|
async def _raw(self, monkeypatch, view, page=None):
|
|
browsers.set_current_view(self.MER, view, 2000, "u1")
|
|
query = browsers._view_query(view)
|
|
url = f"{browsers._EIA_ORIGIN}/{self.MER}" + (f"?{query}" if query else "")
|
|
FakeUpstream(
|
|
{url: ((page if page is not None else self.PAGE).encode(), "text/html")}
|
|
).install(monkeypatch)
|
|
return await browsers.raw_table("total_energy", self.SPEC, "u1")
|
|
|
|
def _periods(self, rows):
|
|
return [k for k in rows[0] if k not in ("category", "units", "source_key")]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_monthly_range_narrows_to_the_slider(self, monkeypatch):
|
|
rows = await self._raw(
|
|
monkeypatch, f"{self.MER}?tbl=T09.01#/?f=M&start=201812&end=201901"
|
|
)
|
|
assert self._periods(rows) == ["2018-12", "2019-01"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_annual_frequency_keeps_yearly_columns(self, monkeypatch):
|
|
rows = await self._raw(monkeypatch, f"{self.MER}?tbl=T09.01#/?f=A")
|
|
assert self._periods(rows) == ["2018 ", "2019 "]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_hash_defaults_to_monthly_full_range(self, monkeypatch):
|
|
rows = await self._raw(monkeypatch, f"{self.MER}?tbl=T09.01")
|
|
assert self._periods(rows) == ["2018-12", "2019-01", "2026-04"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_page_without_inline_data_is_empty(self, monkeypatch):
|
|
assert (
|
|
await self._raw(monkeypatch, f"{self.MER}?tbl=T99", "<html>no</html>") == []
|
|
)
|
|
|
|
def test_hash_state_reads_frequency_and_range(self):
|
|
assert browsers._mer_hash_state(
|
|
f"{self.MER}?tbl=T01.01#/?f=M&start=201812&end=202604"
|
|
) == ("M", "201812", "202604")
|
|
assert browsers._mer_hash_state("no-hash") == ("", "", "")
|
|
|
|
def test_filter_skips_non_dict_rows(self):
|
|
payload = {"ROWS": ["junk", {"DATA": {"201812": "1"}}]}
|
|
browsers._filter_mer_payload(payload, "M", "", "")
|
|
assert payload["ROWS"][1]["DATA"] == {"201812": "1"}
|
|
|
|
|
|
class TestInternationalLabelMaps:
|
|
"""Names for country, unit, product and activity are fetched once and cached."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_labels_fetched_once_then_cached(self, monkeypatch):
|
|
upstream = intl_upstream()
|
|
upstream.install(monkeypatch)
|
|
labels = await browsers._intl_label_maps()
|
|
assert labels["region"] == {"USA": "United States", "OPEC": "OPEC"}
|
|
assert labels["unit"] == {"QBTU": "quad Btu"}
|
|
assert labels["product"] == {"44": "primary energy", "80": "fuel ethanol"}
|
|
assert labels["activity"] == {"1": "production"}
|
|
assert labels["parent"] == {"44": None, "80": 79}
|
|
|
|
await browsers._intl_label_maps()
|
|
assert len(upstream.calls) == len(INTL_FEEDS)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unreachable_labels_degrade_to_codes(self, monkeypatch):
|
|
async def fake_fetch(_target):
|
|
raise OSError("offline")
|
|
|
|
monkeypatch.setattr(browsers, "_fetch_upstream", fake_fetch)
|
|
assert await browsers._intl_label_maps() == {}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_overview_payload_uses_the_infographic_pivot(self, monkeypatch):
|
|
intl_upstream().install(monkeypatch)
|
|
rows = await browsers._intl_rows(
|
|
{
|
|
"data": [
|
|
{
|
|
"series_id": "INTL.44-1-USA-QBTU.A",
|
|
"iso": "USA",
|
|
"unit": "QBTU",
|
|
"frequency": "A",
|
|
"data": [{"date": 1672531200000, "value": 96.34}],
|
|
}
|
|
]
|
|
}
|
|
)
|
|
assert rows == [
|
|
{
|
|
"category": "Production",
|
|
"country": "United States",
|
|
"units": "quad Btu",
|
|
"source_key": "INTL.44-1-USA-QBTU.A",
|
|
"2023 ": 96.34,
|
|
}
|
|
]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unrecognised_payload_is_not_labelled(self):
|
|
assert await browsers._intl_rows({"data": [1, 2]}) is None
|
|
|
|
|
|
IMPORTS_CONFIG = {
|
|
"countries": [{"id": "SA", "name": "Saudi Arabia"}],
|
|
"regions": [{"id": "WORLD", "name": "World"}],
|
|
"opecNonOpec": [{"id": "Y", "name": "OPEC"}, {"id": "N", "name": "Non-OPEC"}],
|
|
"refineries": [{"id": "465", "name": "Motiva Port Arthur"}],
|
|
"ports": [{"id": "US", "name": "Total U.S."}],
|
|
"states": [{"id": "TX", "name": "Texas"}],
|
|
"padds": [{"id": "3", "name": "PADD3 (Gulf Coast)"}],
|
|
"grades": [{"id": "LSO", "name": "Light Sour"}],
|
|
}
|
|
|
|
|
|
class TestBrowserSpecificLabelling:
|
|
"""Imports and NGQS label their tables from sources the payload does not carry."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_imports_config_fetched_once_then_cached(self, monkeypatch):
|
|
browsers._IMPORTS_LABELS.clear()
|
|
upstream = FakeUpstream(
|
|
{
|
|
f"{browsers._EIA_ORIGIN}/{browsers._IMPORTS_CONFIG}": (
|
|
json.dumps(IMPORTS_CONFIG).encode(),
|
|
"application/json",
|
|
)
|
|
}
|
|
)
|
|
upstream.install(monkeypatch)
|
|
labels = await browsers._imports_label_maps()
|
|
assert labels["origin"]["REG"] == {"WORLD": "World"}
|
|
assert labels["origin"]["OPN"]["Y"] == "OPEC"
|
|
assert labels["destination"]["RP"] == {"3": "PADD3 (Gulf Coast)"}
|
|
assert labels["destination"]["RS"] == {"TX": "Texas"}
|
|
assert labels["grade"] == {"LSO": "Light Sour"}
|
|
|
|
await browsers._imports_label_maps()
|
|
assert len(upstream.calls) == 1
|
|
browsers._IMPORTS_LABELS.clear()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unreachable_imports_config_degrades_to_codes(self, monkeypatch):
|
|
browsers._IMPORTS_LABELS.clear()
|
|
|
|
async def fake_fetch(_target):
|
|
raise OSError("offline")
|
|
|
|
monkeypatch.setattr(browsers, "_fetch_upstream", fake_fetch)
|
|
assert await browsers._imports_label_maps() == {}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_imports_payload_is_labelled(self, monkeypatch):
|
|
browsers._IMPORTS_LABELS.clear()
|
|
FakeUpstream(
|
|
{
|
|
f"{browsers._EIA_ORIGIN}/{browsers._IMPORTS_CONFIG}": (
|
|
json.dumps(IMPORTS_CONFIG).encode(),
|
|
"application/json",
|
|
)
|
|
}
|
|
).install(monkeypatch)
|
|
rows = await browsers._labelled_rows(
|
|
"petroleum_imports",
|
|
{
|
|
"TABLE_DATA": [
|
|
{
|
|
"seriesID": "PET_IMPORTS.WORLD-RP_3-LSO.M",
|
|
"data": {"200901": 9091},
|
|
}
|
|
]
|
|
},
|
|
)
|
|
assert rows[0]["category"] == (
|
|
"Imports of Light Sour from World to PADD3 (Gulf Coast), monthly"
|
|
)
|
|
browsers._IMPORTS_LABELS.clear()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_empty_imports_payload_is_not_labelled(self):
|
|
assert (
|
|
await browsers._labelled_rows("petroleum_imports", {"TABLE_DATA": []})
|
|
is None
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_international_overview_ranking_is_labelled(self, monkeypatch):
|
|
browsers._INTL_LABELS.clear()
|
|
feeds = {
|
|
"countries": [{"iso": "CHN", "name": "China"}],
|
|
"political_groups": [],
|
|
"units": [{"code": "QBTU", "short_name": "quad Btu"}],
|
|
"products": [{"id": 44, "name": "primary energy", "parent": None}],
|
|
"activities": [{"id": 1, "name": "production"}],
|
|
}
|
|
FakeUpstream(
|
|
{
|
|
f"{browsers._EIA_ORIGIN}/international/api/{feed}/data": (
|
|
json.dumps({"data": rows}).encode(),
|
|
"application/json",
|
|
)
|
|
for feed, rows in feeds.items()
|
|
}
|
|
).install(monkeypatch)
|
|
rows = await browsers._labelled_rows(
|
|
"international",
|
|
{
|
|
"data": [
|
|
{
|
|
"productid": 44,
|
|
"activityid": 1,
|
|
"frequency": "A",
|
|
"ranking": 1,
|
|
"iso": "CHN",
|
|
"date": "2024-01-01",
|
|
"value": 130.757,
|
|
"unitcode": "QBTU",
|
|
"ug_bmi": 0,
|
|
},
|
|
"not a record",
|
|
]
|
|
},
|
|
)
|
|
assert rows == [
|
|
{
|
|
"category": "Primary energy production",
|
|
"country": "China",
|
|
"rank": 1.0,
|
|
"value": 130.757,
|
|
"units": "quad Btu",
|
|
"period": "2024",
|
|
"source_key": "CHN",
|
|
}
|
|
]
|
|
browsers._INTL_LABELS.clear()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ngqs_payload_uses_its_grid_headers(self):
|
|
rows = await browsers._labelled_rows(
|
|
"natural_gas_query",
|
|
{
|
|
"columns": [{"headerName": "Area", "field": "a"}],
|
|
"data": [{"a": "U.S. Total"}],
|
|
},
|
|
)
|
|
assert rows == [{"Area": "U.S. Total"}]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ngqs_headers_strip_their_html_line_breaks(self):
|
|
rows = await browsers._labelled_rows(
|
|
"natural_gas_query",
|
|
{
|
|
"columns": [
|
|
{"headerName": "Report<BR>State<BR>", "field": "s"},
|
|
{"headerName": "Gas<BR>Field Code", "field": "g", "numeric": True},
|
|
],
|
|
"data": [{"s": "AK", "g": "691992"}],
|
|
},
|
|
)
|
|
assert rows == [{"Report State": "AK", "Gas Field Code": 691992.0}]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_other_browsers_use_the_shared_pivot(self):
|
|
assert await browsers._labelled_rows("electricity", {"TABLEDATA": {}}) is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_non_dict_payload_is_not_labelled(self):
|
|
assert await browsers._labelled_rows("international", []) is None
|
|
|
|
|
|
class TestPlantAndMineLists:
|
|
"""The plant-, mine- and shipment-level views are a record per site."""
|
|
|
|
PAYLOAD = {
|
|
"SINGLE_STATE": False,
|
|
"DATA": [
|
|
{
|
|
"id": 66729,
|
|
"Plant Name": "(3K) 59 Hetcheltown Rd",
|
|
"Plant Code": 66729,
|
|
"State": "NY",
|
|
"Sector Name": "Electric utility non-cogen",
|
|
"lat": 42.87657,
|
|
"lon": -73.91048,
|
|
"HAS_DATA": True,
|
|
}
|
|
],
|
|
"DATA_COLUMNS": ["Plant Name", "Plant Code", "State", "Sector Name"],
|
|
"DESCRIPTION": "List of plants",
|
|
}
|
|
|
|
def test_only_the_columns_the_grid_shows_are_kept(self):
|
|
rows = browsers._plant_list_rows(self.PAYLOAD)
|
|
assert rows == [
|
|
{
|
|
"Plant Name": "(3K) 59 Hetcheltown Rd",
|
|
"Plant Code": 66729,
|
|
"State": "NY",
|
|
"Sector Name": "Electric utility non-cogen",
|
|
}
|
|
]
|
|
|
|
def test_the_record_carries_more_than_the_table_does(self):
|
|
rows = browsers._plant_list_rows(self.PAYLOAD)
|
|
assert "lat" not in rows[0]
|
|
assert "HAS_DATA" not in rows[0]
|
|
|
|
def test_a_plant_list_is_dispatched_by_rows_from_payload(self):
|
|
rows = browsers.rows_from_payload(self.PAYLOAD)
|
|
assert rows[0]["Plant Name"] == "(3K) 59 Hetcheltown Rd"
|
|
|
|
@pytest.mark.parametrize(
|
|
"payload",
|
|
[
|
|
{"DATA": [], "DATA_COLUMNS": ["Plant Name"]},
|
|
{"DATA": ["junk"], "DATA_COLUMNS": ["Plant Name"]},
|
|
{"DATA": [{"a": 1}], "DATA_COLUMNS": [7]},
|
|
{"DATA": [{"a": 1}]},
|
|
{"DATA_COLUMNS": ["Plant Name"]},
|
|
],
|
|
)
|
|
def test_anything_that_is_not_a_site_list_is_declined(self, payload):
|
|
assert browsers._plant_list_rows(payload) is None
|
|
|
|
@pytest.mark.parametrize(
|
|
"method",
|
|
["method=getPlantList", "method=getMineList", "method=getShipmentList"],
|
|
)
|
|
def test_the_site_lists_count_as_table_data(self, method):
|
|
"""These were being discarded, so the plant/mine views served nothing."""
|
|
target = f"https://www.eia.gov/coal/data/browser/data/index.php?{method}"
|
|
assert browsers.is_data_response(target, "application/json") is True
|
|
|
|
|
|
class TestPagedPayload:
|
|
"""A view whose data arrives in pages must be replayed to its end."""
|
|
|
|
def _payload(self, keys, total):
|
|
return {
|
|
"data": {key: {"2023": [1.0, 1.0]} for key in keys},
|
|
"totalCount": total,
|
|
"recordCount": len(keys),
|
|
}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_every_page_is_followed(self, monkeypatch):
|
|
base = (
|
|
"https://www.eia.gov/international/api/series_data/data"
|
|
"?frequency=A&limit=2&offset=0"
|
|
)
|
|
page2 = (
|
|
"https://www.eia.gov/international/api/series_data/data"
|
|
"?frequency=A&limit=2&offset=2"
|
|
)
|
|
FakeUpstream(
|
|
{
|
|
page2: (
|
|
json.dumps(self._payload(["c", "d"], 4)).encode(),
|
|
"application/json",
|
|
)
|
|
}
|
|
).install(monkeypatch)
|
|
merged = await browsers._all_pages({"url": base}, self._payload(["a", "b"], 4))
|
|
assert sorted(merged["data"]) == ["a", "b", "c", "d"]
|
|
assert merged["recordCount"] == 4
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_complete_payload_is_left_alone(self, monkeypatch):
|
|
def unused(_target):
|
|
raise AssertionError("a complete payload must not be re-fetched")
|
|
|
|
monkeypatch.setattr(browsers, "_fetch_sync", unused)
|
|
payload = self._payload(["a", "b"], 2)
|
|
assert (
|
|
await browsers._all_pages({"url": "https://x/y?limit=2"}, payload)
|
|
is payload
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"url",
|
|
[
|
|
"https://www.eia.gov/x?limit=0",
|
|
"https://www.eia.gov/x?limit=nope",
|
|
"https://www.eia.gov/x",
|
|
],
|
|
)
|
|
async def test_a_payload_with_no_usable_page_size_is_left_alone(self, url):
|
|
payload = self._payload(["a"], 9)
|
|
assert await browsers._all_pages({"url": url}, payload) is payload
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"payload",
|
|
[
|
|
{"data": [1, 2]},
|
|
{"data": {"a": {}}, "totalCount": "many"},
|
|
"not a payload",
|
|
],
|
|
)
|
|
async def test_an_unpaged_shape_is_left_alone(self, payload):
|
|
assert (
|
|
await browsers._all_pages({"url": "https://x?limit=2"}, payload) is payload
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_broken_page_stops_the_walk(self, monkeypatch):
|
|
base = "https://www.eia.gov/x?limit=2&offset=0"
|
|
FakeUpstream(
|
|
{
|
|
"https://www.eia.gov/x?limit=2&offset=2": (
|
|
b"not json",
|
|
"application/json",
|
|
)
|
|
}
|
|
).install(monkeypatch)
|
|
merged = await browsers._all_pages({"url": base}, self._payload(["a", "b"], 6))
|
|
assert sorted(merged["data"]) == ["a", "b"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_an_empty_page_stops_the_walk(self, monkeypatch):
|
|
base = "https://www.eia.gov/x?limit=2&offset=0"
|
|
FakeUpstream(
|
|
{
|
|
"https://www.eia.gov/x?limit=2&offset=2": (
|
|
json.dumps({"data": {}}).encode(),
|
|
"application/json",
|
|
)
|
|
}
|
|
).install(monkeypatch)
|
|
merged = await browsers._all_pages({"url": base}, self._payload(["a", "b"], 6))
|
|
assert sorted(merged["data"]) == ["a", "b"]
|
|
|
|
|
|
class TestRecordedViewOrdering:
|
|
"""A late report must never drag the current view backwards."""
|
|
|
|
def test_an_older_report_does_not_overwrite_a_newer_one(self):
|
|
path = ELECTRICITY
|
|
browsers.set_data_target(path, "view/new", {"url": "NEW"}, 2000)
|
|
browsers.set_data_target(path, "view/old", {"url": "OLD"}, 1000)
|
|
assert browsers.get_data_target(path)["url"] == "NEW"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_data_response_records_the_view_that_asked_for_it(
|
|
self, monkeypatch
|
|
):
|
|
target = "https://www.eia.gov/international/api/data/data?x=1"
|
|
FakeUpstream({target: (b'{"response":{}}', "application/json")}).install(
|
|
monkeypatch
|
|
)
|
|
await browsers.eia_proxy(
|
|
"international/api/data/data",
|
|
make_info(
|
|
"/api/v1/eia_proxy/international/api/data/data",
|
|
query=f"x=1&obb_browser={INTERNATIONAL}"
|
|
"&obb_view=international%2Fdata&obb_seq=5",
|
|
),
|
|
)
|
|
assert browsers.get_current_view(INTERNATIONAL) == "international/data"
|
|
assert browsers.get_data_target(INTERNATIONAL)["url"] == target
|
|
|
|
|
|
class TestInternationalRawEndToEnd:
|
|
"""International's grid API is unreachable, so raw is served from its payload."""
|
|
|
|
LABEL_FEEDS = {
|
|
"countries": [{"iso": "USA", "name": "United States"}],
|
|
"political_groups": [],
|
|
"units": [{"code": "QBTU", "short_name": "quad Btu"}],
|
|
"products": [{"id": 44, "name": "primary energy", "parent": None}],
|
|
"activities": [{"id": 1, "name": "production"}],
|
|
}
|
|
|
|
def _upstream(self, target, payload):
|
|
responses = {
|
|
f"{browsers._EIA_ORIGIN}/international/api/{feed}/data": (
|
|
json.dumps({"data": rows}).encode(),
|
|
"application/json",
|
|
)
|
|
for feed, rows in self.LABEL_FEEDS.items()
|
|
}
|
|
responses[target] = (json.dumps(payload).encode(), "application/json")
|
|
return FakeUpstream(responses)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_raw_serves_the_view_the_page_is_showing(self, monkeypatch):
|
|
spec = browsers.EIA_DATA_BROWSERS["international"]
|
|
target = f"{browsers._EIA_ORIGIN}/international/api/series_data/data"
|
|
payload = {"data": {"INTL.44-1-USA-QBTU.A": {"2023": [96.0, 96.34]}}}
|
|
self._upstream(target, payload).install(monkeypatch)
|
|
|
|
browsers.set_current_view(spec["path"], "international/data/world")
|
|
browsers.set_data_target(
|
|
spec["path"], "international/data/world", {"url": target, "method": "GET"}
|
|
)
|
|
rows = await browsers.raw_table("international", spec)
|
|
assert rows == [
|
|
{
|
|
"category": "Production",
|
|
"country": "United States",
|
|
"units": "quad Btu",
|
|
"source_key": "INTL.44-1-USA-QBTU.A",
|
|
"2023 ": 96.34,
|
|
}
|
|
]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_the_widget_endpoint_serves_those_rows(self, monkeypatch):
|
|
spec = browsers.EIA_DATA_BROWSERS["international"]
|
|
target = f"{browsers._EIA_ORIGIN}/international/api/series_data/data"
|
|
payload = {"data": {"INTL.44-1-USA-QBTU.A": {"2023": [96.0, 96.34]}}}
|
|
self._upstream(target, payload).install(monkeypatch)
|
|
browsers.set_current_view(spec["path"], "international/data/world")
|
|
browsers.set_data_target(
|
|
spec["path"], "international/data/world", {"url": target, "method": "GET"}
|
|
)
|
|
|
|
routes = {r.path: r for r in browsers.router._api_router.routes}
|
|
endpoint = routes["/international_browser"].endpoint
|
|
response = await endpoint(
|
|
theme="dark", raw=True, info=make_info("/api/v1/international_browser")
|
|
)
|
|
assert json.loads(response.body)[0]["country"] == "United States"
|
|
|
|
|
|
class TestBrowserEndpointNoRequest:
|
|
@pytest.mark.asyncio
|
|
async def test_raw_endpoint_without_request_info_unwraps_json(self, monkeypatch):
|
|
endpoint = browsers._browser_endpoint("international")
|
|
|
|
async def fake_render(_browser, _theme, _raw, _info):
|
|
return browsers.JSONResponse(
|
|
content=[{"row": 1}], headers={"Cache-Control": "no-store"}
|
|
)
|
|
|
|
monkeypatch.setattr(browsers, "render_browser", fake_render)
|
|
out = await endpoint(theme="dark", raw=True)
|
|
assert out == [{"row": 1}]
|
|
|
|
|
|
class TestMapContrast:
|
|
"""The map series is unreadable at eia.gov's own colours on a white page."""
|
|
|
|
def _page(self, dark):
|
|
return browsers.rewrite_html(
|
|
"<head></head>", PROXY_PREFIX, dark=dark, browser=INTERNATIONAL
|
|
)
|
|
|
|
@pytest.mark.parametrize("dark", [False, True])
|
|
def test_regions_with_no_value_are_given_a_readable_fill(self, dark):
|
|
page = self._page(dark)
|
|
assert (
|
|
".highcharts-map-series .highcharts-null-point{fill:#dde2e8!important}"
|
|
in page
|
|
)
|
|
|
|
@pytest.mark.parametrize("dark", [False, True])
|
|
def test_every_region_is_outlined_so_the_shapes_read(self, dark):
|
|
page = self._page(dark)
|
|
assert (
|
|
".highcharts-map-series .highcharts-point"
|
|
"{stroke:#98a2b0!important;stroke-width:0.7px!important}" in page
|
|
)
|
|
|
|
def test_a_choropleths_own_colours_are_left_alone(self):
|
|
"""A shaded map's fill is the data; only no-data regions may be recoloured."""
|
|
page = self._page(False)
|
|
assert ".highcharts-map-series .highcharts-point{fill:" not in page
|