mirror of
https://github.com/OpenBB-finance/OpenBB.git
synced 2026-10-27 12:40:35 +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>
1902 lines
67 KiB
Python
1902 lines
67 KiB
Python
"""Tests for openbb_cli.dispatchers.socrata — story → spec ingestion."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
from collections.abc import Awaitable, Callable
|
|
from typing import Any
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from openbb_cli.dispatchers import socrata
|
|
from openbb_cli.dispatchers.spec import SpecDocument
|
|
|
|
|
|
def _run_with_mock_client(
|
|
handler: Callable[[httpx.Request], httpx.Response],
|
|
operation: Callable[[httpx.AsyncClient], Awaitable[Any]],
|
|
) -> Any:
|
|
"""Run an async operation against an ``httpx.AsyncClient`` whose
|
|
transport is mocked by ``handler``.
|
|
|
|
Lets sync test functions exercise the async network paths without
|
|
pulling in pytest-asyncio — each test owns its own event loop via
|
|
``asyncio.run``.
|
|
"""
|
|
|
|
async def go() -> Any:
|
|
transport = httpx.MockTransport(handler)
|
|
async with httpx.AsyncClient(transport=transport) as client:
|
|
return await operation(client)
|
|
|
|
return asyncio.run(go())
|
|
|
|
|
|
# --- _slugify ---
|
|
|
|
|
|
def test_slugify_lowercases_and_collapses_non_alnum_to_underscore():
|
|
assert socrata._slugify("Waterborne Agricultural Trade Data!") == (
|
|
"waterborne_agricultural_trade_data"
|
|
)
|
|
|
|
|
|
def test_slugify_prefixes_underscore_when_starts_with_digit():
|
|
assert socrata._slugify("2024 Quarterly Report") == "_2024_quarterly_report"
|
|
|
|
|
|
def test_slugify_falls_back_to_dataset_when_input_is_only_punctuation():
|
|
assert socrata._slugify("---") == "dataset"
|
|
|
|
|
|
# --- extract_dataset_uids / _walk_for_dataset_uids ---
|
|
|
|
|
|
def test_extract_dataset_uids_walks_nested_blocks():
|
|
"""Stories nest ``datasetUid`` deep inside layout/component trees."""
|
|
story = {
|
|
"blocks": [
|
|
{
|
|
"components": [
|
|
{"vif": {"dataSource": {"datasetUid": "aaaa-bbbb"}}},
|
|
{"vif": {"dataSource": {"datasetUid": "cccc-dddd"}}},
|
|
]
|
|
},
|
|
{"layout": {"sections": [{"datasetUid": "aaaa-bbbb"}]}}, # dup
|
|
]
|
|
}
|
|
assert socrata.extract_dataset_uids(story) == ["aaaa-bbbb", "cccc-dddd"]
|
|
|
|
|
|
def test_extract_dataset_uids_ignores_non_string_dataset_uid_values():
|
|
"""A ``datasetUid: null`` (sometimes appears in disabled blocks) is skipped."""
|
|
story = {"blocks": [{"datasetUid": None}, {"datasetUid": "real-data"}]}
|
|
assert socrata.extract_dataset_uids(story) == ["real-data"]
|
|
|
|
|
|
def test_extract_dataset_uids_ignores_4x4_strings_outside_dataset_uid_keys():
|
|
"""A 4x4-shaped string in body text isn't a real reference — only
|
|
values keyed by ``datasetUid`` count."""
|
|
story = {
|
|
"title": "Story xxxx-yyyy is not a dataset reference",
|
|
"blocks": [{"datasetUid": "real-data"}],
|
|
}
|
|
assert socrata.extract_dataset_uids(story) == ["real-data"]
|
|
|
|
|
|
def test_extract_dataset_uids_returns_empty_when_story_has_no_datasets():
|
|
assert socrata.extract_dataset_uids({"blocks": []}) == []
|
|
|
|
|
|
# --- _coerce_choices_to_type ---
|
|
|
|
|
|
def test_coerce_choices_to_type_casts_checkbox_strings_to_booleans():
|
|
"""Socrata serializes ``checkbox`` distinct values as the strings
|
|
``"true"`` / ``"false"`` — proper parsing (not naive ``bool()``,
|
|
which would call ``"false"`` truthy)."""
|
|
assert socrata._coerce_choices_to_type(
|
|
["true", "false", None, True, "garbage"], "checkbox"
|
|
) == [True, False, True]
|
|
|
|
|
|
def test_coerce_choices_to_type_passes_through_non_numeric_non_checkbox_types():
|
|
"""``string`` / ``date`` columns just hand the raw values back."""
|
|
assert socrata._coerce_choices_to_type(["a", "b"], "text") == ["a", "b"]
|
|
|
|
|
|
# --- _is_categorical_column ---
|
|
|
|
|
|
def test_is_categorical_column_returns_true_for_text_and_html_only():
|
|
assert socrata._is_categorical_column({"dataTypeName": "text"}) is True
|
|
assert socrata._is_categorical_column({"dataTypeName": "html"}) is True
|
|
assert socrata._is_categorical_column({"dataTypeName": "number"}) is False
|
|
assert socrata._is_categorical_column({"dataTypeName": "calendar_date"}) is False
|
|
# Defaults to ``"text"`` when ``dataTypeName`` is missing.
|
|
assert socrata._is_categorical_column({}) is True
|
|
|
|
|
|
# --- fetch_column_distinct_values defensive branch ---
|
|
|
|
|
|
def test_afetch_column_distinct_values_skips_non_dict_rows_in_response():
|
|
"""A SoQL response with a stray scalar in the rows list (malformed
|
|
upstream payload) doesn't crash — non-dict entries are silently
|
|
skipped."""
|
|
|
|
def handler(_request):
|
|
return httpx.Response(200, json=[{"port": "A"}, "garbage", {"port": "B"}])
|
|
|
|
out = _run_with_mock_client(
|
|
handler,
|
|
lambda c: socrata.afetch_column_distinct_values(
|
|
c, "https://x", "uid-1", "port"
|
|
),
|
|
)
|
|
assert out == ["A", "B"]
|
|
|
|
|
|
# --- _build_query_parameters defensive branches ---
|
|
|
|
|
|
def test_build_query_parameters_skips_non_dict_columns_and_unnamed_columns():
|
|
"""Defensive: a stray non-dict entry in ``columns`` and a column with
|
|
no ``fieldName`` / ``name`` both get skipped — only well-formed
|
|
columns produce filter params."""
|
|
meta = {
|
|
"columns": [
|
|
"not-a-dict", # skipped — non-dict
|
|
{"dataTypeName": "text"}, # skipped — no fieldName
|
|
{"fieldName": "ok", "dataTypeName": "text"},
|
|
]
|
|
}
|
|
params = socrata._build_query_parameters(meta)
|
|
column_names = [p["name"] for p in params if p["name"] not in ("limit", "offset")]
|
|
assert column_names == ["ok"]
|
|
|
|
|
|
# --- build_socrata_spec command-path collision branch ---
|
|
|
|
|
|
def test_build_socrata_spec_disambiguates_duplicate_command_paths(tmp_path):
|
|
"""Two datasets that slugify to the same router AND command (e.g.
|
|
identical names from different data eras) get the second's path
|
|
suffixed with the UID so commands don't clobber each other."""
|
|
story_path = tmp_path / "story.json"
|
|
story_path.write_text(
|
|
json.dumps(
|
|
{
|
|
"dataSource": {"domainCName": "x.com"},
|
|
"blocks": [
|
|
{"datasetUid": "abcd-1111"},
|
|
{"datasetUid": "efgh-2222"},
|
|
],
|
|
}
|
|
)
|
|
)
|
|
|
|
def stub_fetch(host, uid): # noqa: ARG001
|
|
# Both datasets share the exact same name → same slug → collision.
|
|
return {
|
|
"assetType": "dataset",
|
|
"name": "Quarterly Sales",
|
|
"columns": [{"fieldName": "x", "dataTypeName": "text"}],
|
|
}
|
|
|
|
spec_doc = socrata.build_socrata_spec(
|
|
str(story_path),
|
|
fetch=stub_fetch,
|
|
choice_resolver=_no_choice_resolver,
|
|
)
|
|
# Both are singletons (different first tokens, same full slug) — they
|
|
# land at the spec root with the second one disambiguated by UID.
|
|
paths = sorted(spec_doc["commands"])
|
|
assert paths == [
|
|
"quarterly_sales",
|
|
"quarterly_sales_efgh2222",
|
|
]
|
|
|
|
|
|
# --- _column_to_schema / _build_response_schema ---
|
|
|
|
|
|
def test_column_to_schema_maps_calendar_date_to_plain_date_format():
|
|
"""Socrata's ``calendar_date`` columns serialize as ISO timestamps on
|
|
the wire but the time component is always zeros — model the field as
|
|
``format: date`` so the response row carries a clean ``date`` value
|
|
instead of a ``datetime`` with a noisy 00:00:00 suffix."""
|
|
assert socrata._column_to_schema(
|
|
{"fieldName": "monthyear", "dataTypeName": "calendar_date"}
|
|
) == {"type": "string", "format": "date"}
|
|
|
|
|
|
def test_column_to_schema_captures_currency_format_hints():
|
|
"""Socrata's ``format.precisionStyle`` (``currency`` / ``percent``)
|
|
plus locale separators land under ``socrata_format`` so downstream
|
|
formatters know to render ``$763.75`` instead of ``763.75``."""
|
|
out = socrata._column_to_schema(
|
|
{
|
|
"fieldName": "price",
|
|
"dataTypeName": "number",
|
|
"description": "Price, measured in dollars per ton.",
|
|
"format": {
|
|
"precisionStyle": "currency",
|
|
"decimalSeparator": ".",
|
|
"groupSeparator": ",",
|
|
"align": "right", # internal hint — should be filtered out
|
|
},
|
|
}
|
|
)
|
|
assert out["type"] == "number"
|
|
assert out["description"] == "Price, measured in dollars per ton."
|
|
assert out["socrata_format"] == {
|
|
"precisionStyle": "currency",
|
|
"decimalSeparator": ".",
|
|
"groupSeparator": ",",
|
|
}
|
|
|
|
|
|
def test_column_to_schema_omits_socrata_format_when_no_hints():
|
|
"""A column with no ``format`` block (or only internal keys) gets
|
|
no ``socrata_format`` key — keeps the schema lean."""
|
|
out = socrata._column_to_schema({"fieldName": "name", "dataTypeName": "text"})
|
|
assert "socrata_format" not in out
|
|
|
|
|
|
def test_column_to_schema_keeps_description():
|
|
out = socrata._column_to_schema(
|
|
{
|
|
"fieldName": "exim",
|
|
"dataTypeName": "text",
|
|
"description": "Identifies either an import and export shipment",
|
|
}
|
|
)
|
|
assert out["type"] == "string"
|
|
assert out["description"] == "Identifies either an import and export shipment"
|
|
|
|
|
|
def test_column_to_schema_strips_trailing_whitespace_from_description():
|
|
"""Socrata descriptions frequently arrive with trailing whitespace
|
|
("Quarter number. ", "Price, measured in dollars per ton. "). Strip
|
|
both edges so downstream renderers don't have to see the noise.
|
|
"""
|
|
out = socrata._column_to_schema(
|
|
{
|
|
"fieldName": "price",
|
|
"dataTypeName": "number",
|
|
"description": " Price, measured in dollars per ton. \n",
|
|
}
|
|
)
|
|
assert out["description"] == "Price, measured in dollars per ton."
|
|
|
|
|
|
def test_column_to_schema_drops_description_when_only_whitespace():
|
|
"""A description that is *only* whitespace shouldn't survive — it would
|
|
leave an empty ``description`` field that's worse than absent."""
|
|
out = socrata._column_to_schema(
|
|
{"fieldName": "x", "dataTypeName": "text", "description": " \n "}
|
|
)
|
|
assert "description" not in out
|
|
|
|
|
|
def test_column_to_schema_falls_back_to_string_for_unknown_type():
|
|
"""An unrecognized ``dataTypeName`` collapses to ``string`` so codegen
|
|
still produces a usable field rather than crashing."""
|
|
out = socrata._column_to_schema(
|
|
{"fieldName": "weird", "dataTypeName": "unknown_socrata_type"}
|
|
)
|
|
assert out == {"type": "string"}
|
|
|
|
|
|
def test_column_to_schema_url_type_renders_as_object_with_url_and_description():
|
|
out = socrata._column_to_schema({"fieldName": "link", "dataTypeName": "url"})
|
|
assert out["type"] == "object"
|
|
assert "url" in out["properties"]
|
|
assert "description" in out["properties"]
|
|
|
|
|
|
def test_build_response_schema_wraps_columns_in_results_envelope():
|
|
"""Output is ``{results: array<row>}`` so the existing schema unwrap
|
|
descends single-array properties to the row shape."""
|
|
meta = {
|
|
"columns": [
|
|
{"fieldName": "year", "dataTypeName": "text"},
|
|
{"fieldName": "amount", "dataTypeName": "number"},
|
|
]
|
|
}
|
|
out = socrata._build_response_schema(meta)
|
|
assert out["type"] == "object"
|
|
assert "results" in out["properties"]
|
|
items = out["properties"]["results"]["items"]
|
|
assert items["properties"]["year"] == {"type": "string"}
|
|
assert items["properties"]["amount"] == {"type": "number"}
|
|
|
|
|
|
def test_build_response_schema_skips_columns_without_field_name():
|
|
meta = {
|
|
"columns": [
|
|
{"dataTypeName": "text"}, # no fieldName -> skip
|
|
{"fieldName": "ok", "dataTypeName": "text"},
|
|
]
|
|
}
|
|
out = socrata._build_response_schema(meta)
|
|
assert list(out["properties"]["results"]["items"]["properties"]) == ["ok"]
|
|
|
|
|
|
def test_build_response_schema_handles_non_dict_column_entries():
|
|
"""Defensive: a stray non-dict in the columns list doesn't crash."""
|
|
out = socrata._build_response_schema(
|
|
{"columns": ["not-a-dict", {"fieldName": "ok", "dataTypeName": "text"}]}
|
|
)
|
|
assert "ok" in out["properties"]["results"]["items"]["properties"]
|
|
|
|
|
|
# --- _build_command ---
|
|
|
|
|
|
def test_build_command_uses_description_first_for_repl_menu_one_liner():
|
|
"""REPL menu renderer keeps only the first sentence
|
|
(``description.split('.')[0]``) — leading with the dataset name
|
|
would duplicate the command label and waste that one-line summary
|
|
slot. So the description's first sentence is the prose, not the
|
|
name."""
|
|
cmd = socrata._build_command(
|
|
"https://portal",
|
|
"abcd-1234",
|
|
{"name": "Monthly Sales", "description": "Sales aggregated by month."},
|
|
)
|
|
assert cmd["url_path"] == "/resource/abcd-1234.json"
|
|
assert cmd["method"] == "get"
|
|
# First sentence (what the menu shows) is the prose, not the name.
|
|
assert cmd["description"].startswith("Sales aggregated by month")
|
|
|
|
|
|
def test_build_command_falls_back_to_name_when_no_description():
|
|
"""A dataset with no description uses the name as summary so the
|
|
menu still has something to show."""
|
|
cmd = socrata._build_command(
|
|
"https://portal", "abcd-1234", {"name": "Monthly Sales"}
|
|
)
|
|
assert cmd["description"] == "Monthly Sales"
|
|
|
|
|
|
def _no_choice_resolver(host, uid, field, **kwargs): # noqa: ARG001
|
|
"""Default test stub — every column resolves to ``None`` (overflow).
|
|
|
|
Accepts ``**kwargs`` so production-side ``limit=`` calls land safely.
|
|
"""
|
|
return None
|
|
|
|
|
|
def test_build_command_emits_one_filter_param_per_column_plus_pagination():
|
|
"""One ``?<col>=<val>`` filter param per column, plus clean
|
|
``limit`` / ``offset`` (the SoQL ``$``-prefix lives in ``wire_name``
|
|
and gets applied by the dispatcher — never surfaces to the user)."""
|
|
meta = {
|
|
"name": "Trade",
|
|
"columns": [
|
|
{"fieldName": "year", "dataTypeName": "text"},
|
|
{"fieldName": "amount", "dataTypeName": "number"},
|
|
],
|
|
}
|
|
cmd = socrata._build_command(
|
|
"https://x", "uid-1", meta, choice_resolver=_no_choice_resolver
|
|
)
|
|
names = [p["name"] for p in cmd["parameters"]]
|
|
assert names == ["year", "amount", "limit", "offset"]
|
|
# No ``$``-prefixed names anywhere on the user-facing surface.
|
|
assert all(not p["name"].startswith("$") for p in cmd["parameters"])
|
|
# Pagination params carry the SoQL wire form for the dispatcher.
|
|
by_name = {p["name"]: p for p in cmd["parameters"]}
|
|
assert by_name["limit"]["wire_name"] == "$limit"
|
|
assert by_name["offset"]["wire_name"] == "$offset"
|
|
|
|
|
|
def test_build_command_uses_resolver_to_populate_column_choices():
|
|
"""``_build_command`` issues a SoQL probe per column via the resolver
|
|
and surfaces the returned distinct values as ``choices``."""
|
|
|
|
def resolver(host, uid, field, **kwargs): # noqa: ARG001
|
|
if field == "exim":
|
|
return ["Import", "Export"]
|
|
return None # other columns don't fit a closed set
|
|
|
|
meta = {
|
|
"name": "Trade",
|
|
"columns": [
|
|
{"fieldName": "exim", "dataTypeName": "text"},
|
|
{"fieldName": "port", "dataTypeName": "text"},
|
|
],
|
|
}
|
|
cmd = socrata._build_command("https://x", "uid-1", meta, choice_resolver=resolver)
|
|
by_name = {p["name"]: p for p in cmd["parameters"]}
|
|
assert by_name["exim"]["choices"] == ["Import", "Export"]
|
|
assert by_name["port"]["choices"] == []
|
|
|
|
|
|
def test_build_command_column_type_maps_money_and_percent_to_number():
|
|
meta = {
|
|
"name": "Trade",
|
|
"columns": [
|
|
{"fieldName": "revenue", "dataTypeName": "money"},
|
|
{"fieldName": "share", "dataTypeName": "percent"},
|
|
{"fieldName": "active", "dataTypeName": "checkbox"},
|
|
{"fieldName": "city", "dataTypeName": "text"},
|
|
],
|
|
}
|
|
cmd = socrata._build_command(
|
|
"https://x", "uid-1", meta, choice_resolver=_no_choice_resolver
|
|
)
|
|
by_name = {p["name"]: p["type"] for p in cmd["parameters"]}
|
|
assert by_name["revenue"] == "number"
|
|
assert by_name["share"] == "number"
|
|
assert by_name["active"] == "boolean"
|
|
assert by_name["city"] == "string"
|
|
|
|
|
|
def test_build_command_pagination_defaults_match_socrata_api_defaults():
|
|
cmd = socrata._build_command(
|
|
"https://x", "uid-1", {"name": "n"}, choice_resolver=_no_choice_resolver
|
|
)
|
|
by_name = {p["name"]: p for p in cmd["parameters"]}
|
|
assert by_name["limit"]["default"] == 1000
|
|
assert by_name["limit"]["type"] == "integer"
|
|
assert by_name["offset"]["default"] is None
|
|
assert by_name["offset"]["type"] == "integer"
|
|
|
|
|
|
def test_build_command_date_column_emits_uniform_start_end_date_pair():
|
|
"""Every dataset with a date column gets ``start_date`` / ``end_date``
|
|
— names are uniform across datasets so callers don't need to look up
|
|
the underlying column name. The SoQL ``$where`` translation in the
|
|
dispatcher uses ``_socrata_column`` to address the actual column."""
|
|
meta = {
|
|
"name": "Trade",
|
|
"columns": [
|
|
{
|
|
"fieldName": "monthyear",
|
|
"dataTypeName": "calendar_date",
|
|
"description": "Month and year of the shipment",
|
|
"cachedContents": {
|
|
"smallest": "2010-01-01T00:00:00.000",
|
|
"largest": "2025-12-01T00:00:00.000",
|
|
},
|
|
}
|
|
],
|
|
}
|
|
cmd = socrata._build_command(
|
|
"https://x", "uid-1", meta, choice_resolver=_no_choice_resolver
|
|
)
|
|
names = [p["name"] for p in cmd["parameters"]]
|
|
# Unified naming — no per-column ``monthyear_start`` etc.
|
|
assert "monthyear" not in names
|
|
assert "monthyear_start" not in names
|
|
assert "start_date" in names and "end_date" in names
|
|
|
|
by_name = {p["name"]: p for p in cmd["parameters"]}
|
|
start = by_name["start_date"]
|
|
end = by_name["end_date"]
|
|
# Defaults are ``None`` so omitting the params returns everything the
|
|
# other filters allow — clamping to the cached bounds would silently
|
|
# exclude rows added after the metadata snapshot.
|
|
assert start["default"] is None
|
|
assert end["default"] is None
|
|
# Cached bounds (stripped to plain ``YYYY-MM-DD``) survive in
|
|
# ``example`` so callers still see the available range up front.
|
|
assert start["example"] == "2010-01-01"
|
|
assert end["example"] == "2025-12-01"
|
|
assert start["_socrata_op"] == "date_min"
|
|
assert end["_socrata_op"] == "date_max"
|
|
# ``_socrata_column`` carries the actual column name for the SoQL builder.
|
|
assert start["_socrata_column"] == "monthyear"
|
|
assert end["_socrata_column"] == "monthyear"
|
|
assert "monthyear" in start["help"]
|
|
assert "Dataset spans" in start["help"]
|
|
# Help line tells the user the expected format and the omit semantics.
|
|
assert "YYYY-MM-DD" in start["help"]
|
|
assert "Omit" in start["help"]
|
|
|
|
|
|
def test_build_command_date_column_without_cached_bounds_still_emits_range_params():
|
|
"""A date column with no smallest/largest still gets the range params,
|
|
just with ``None`` defaults — the user can supply bounds manually."""
|
|
meta = {
|
|
"name": "Trade",
|
|
"columns": [{"fieldName": "ts", "dataTypeName": "calendar_date"}],
|
|
}
|
|
cmd = socrata._build_command(
|
|
"https://x", "uid-1", meta, choice_resolver=_no_choice_resolver
|
|
)
|
|
by_name = {p["name"]: p for p in cmd["parameters"]}
|
|
assert "start_date" in by_name and "end_date" in by_name
|
|
assert by_name["start_date"]["default"] is None
|
|
assert by_name["end_date"]["default"] is None
|
|
# Still keyed on the actual column for the SoQL translator.
|
|
assert by_name["start_date"]["_socrata_column"] == "ts"
|
|
|
|
|
|
def test_to_plain_date_strips_socrata_timestamp_suffix():
|
|
assert socrata._to_plain_date("2010-01-01T00:00:00.000") == "2010-01-01"
|
|
|
|
|
|
def test_to_plain_date_passes_through_already_plain_date():
|
|
assert socrata._to_plain_date("2025-12-31") == "2025-12-31"
|
|
|
|
|
|
def test_to_plain_date_passes_through_non_date_strings_unchanged():
|
|
"""A garbled value isn't a date — return it as-is so the spec
|
|
consumer can decide what to do with it."""
|
|
assert socrata._to_plain_date("not-a-date") == "not-a-date"
|
|
|
|
|
|
def test_to_plain_date_passes_through_non_string_values():
|
|
assert socrata._to_plain_date(None) is None
|
|
assert socrata._to_plain_date(42) == 42
|
|
|
|
|
|
def test_build_command_with_multiple_date_columns_emits_only_one_range_pair():
|
|
"""Only the first date column drives ``start_date`` / ``end_date`` —
|
|
additional date columns fall back to plain equality filters so the
|
|
parameter list stays predictable."""
|
|
meta = {
|
|
"name": "Audits",
|
|
"columns": [
|
|
{
|
|
"fieldName": "audit_date",
|
|
"dataTypeName": "calendar_date",
|
|
"cachedContents": {
|
|
"smallest": "2020-01-01",
|
|
"largest": "2025-01-01",
|
|
},
|
|
},
|
|
{
|
|
"fieldName": "review_date",
|
|
"dataTypeName": "calendar_date",
|
|
},
|
|
],
|
|
}
|
|
cmd = socrata._build_command(
|
|
"https://x", "uid-1", meta, choice_resolver=_no_choice_resolver
|
|
)
|
|
by_name = {p["name"]: p for p in cmd["parameters"]}
|
|
assert by_name["start_date"]["_socrata_column"] == "audit_date"
|
|
assert by_name["end_date"]["_socrata_column"] == "audit_date"
|
|
# Second date column does NOT spawn another start_date / end_date.
|
|
names = [p["name"] for p in cmd["parameters"]]
|
|
assert names.count("start_date") == 1
|
|
assert names.count("end_date") == 1
|
|
# Second date column simply gets dropped (it's not useful as an
|
|
# equality filter and we don't want to confuse with the primary
|
|
# date range).
|
|
assert "review_date" not in by_name
|
|
|
|
|
|
def test_build_command_falls_back_to_cached_top_when_resolver_returns_none(
|
|
monkeypatch,
|
|
):
|
|
"""When the SoQL resolver fails (overflow / network), columns whose
|
|
``cachedContents.top`` is below the 20-cap fall back to that list —
|
|
so the user still sees something useful even if the live query
|
|
didn't pan out."""
|
|
|
|
def overflow_resolver(host, uid, field, **kwargs): # noqa: ARG001
|
|
return None
|
|
|
|
meta = {
|
|
"name": "Trade",
|
|
"columns": [
|
|
{
|
|
"fieldName": "exim",
|
|
"dataTypeName": "text",
|
|
"cachedContents": {
|
|
"top": [{"item": "Import"}, {"item": "Export"}],
|
|
},
|
|
},
|
|
{
|
|
"fieldName": "port",
|
|
"dataTypeName": "text",
|
|
"cachedContents": {
|
|
"top": [{"item": f"P{i}"} for i in range(20)],
|
|
},
|
|
},
|
|
],
|
|
}
|
|
cmd = socrata._build_command(
|
|
"https://x", "uid-1", meta, choice_resolver=overflow_resolver
|
|
)
|
|
by_name = {p["name"]: p for p in cmd["parameters"]}
|
|
# Below-cap cached top survives as fallback choices.
|
|
assert by_name["exim"]["choices"] == ["Import", "Export"]
|
|
# At-cap cached top stays empty — it would mislead to call those the full set.
|
|
assert by_name["port"]["choices"] == []
|
|
|
|
|
|
def test_build_command_falls_back_to_uid_when_meta_has_no_name():
|
|
cmd = socrata._build_command("https://x", "fall-back", {})
|
|
assert "fall-back" in cmd["description"]
|
|
|
|
|
|
# --- _story_host ---
|
|
|
|
|
|
def test_story_host_prefers_data_source_domain_when_present():
|
|
assert (
|
|
socrata._story_host(
|
|
"https://other.example.com/stories/s/x.json",
|
|
{"dataSource": {"domainCName": "agtransport.usda.gov"}},
|
|
)
|
|
== "https://agtransport.usda.gov"
|
|
)
|
|
|
|
|
|
def test_story_host_falls_back_to_url_host_when_domain_missing():
|
|
assert (
|
|
socrata._story_host(
|
|
"https://agtransport.usda.gov/stories/s/x.json",
|
|
{"dataSource": {}},
|
|
)
|
|
== "https://agtransport.usda.gov"
|
|
)
|
|
|
|
|
|
def test_story_host_raises_when_both_domain_and_url_unavailable(tmp_path):
|
|
"""A local-file story without ``domainCName`` has no host to infer."""
|
|
with pytest.raises(ValueError, match="domainCName"):
|
|
socrata._story_host("/local/path.json", {"dataSource": {}})
|
|
|
|
|
|
# --- _backing_dataset_uid ---
|
|
|
|
|
|
def test_backing_dataset_uid_prefers_modifying_view_uid():
|
|
"""Modern Socrata stores the backing reference in ``modifyingViewUid``."""
|
|
assert (
|
|
socrata._backing_dataset_uid({"modifyingViewUid": "abcd-1234"}) == "abcd-1234"
|
|
)
|
|
|
|
|
|
def test_backing_dataset_uid_falls_back_to_query_view_source_id():
|
|
"""Legacy assets put the same reference in ``query.viewSourceId``."""
|
|
assert (
|
|
socrata._backing_dataset_uid({"query": {"viewSourceId": "wxyz-9999"}})
|
|
== "wxyz-9999"
|
|
)
|
|
|
|
|
|
def test_backing_dataset_uid_returns_none_when_no_reference_present():
|
|
assert socrata._backing_dataset_uid({"name": "standalone"}) is None
|
|
|
|
|
|
def test_backing_dataset_uid_ignores_non_string_values():
|
|
"""Defensive: a malformed ``modifyingViewUid: null`` doesn't crash."""
|
|
assert socrata._backing_dataset_uid({"modifyingViewUid": None}) is None
|
|
assert socrata._backing_dataset_uid({"query": {"viewSourceId": 42}}) is None
|
|
|
|
|
|
# --- chart-view following in build_socrata_spec ---
|
|
|
|
|
|
def test_build_socrata_spec_follows_chart_view_to_backing_dataset(tmp_path):
|
|
"""A story that only references a chart view still ends up with the
|
|
chart's backing dataset in the generated spec — the chart UID itself
|
|
gets skipped, but its ``modifyingViewUid`` gets fetched."""
|
|
story_path = tmp_path / "story.json"
|
|
story_path.write_text(
|
|
json.dumps(
|
|
{
|
|
"dataSource": {"domainCName": "x.com"},
|
|
"blocks": [{"datasetUid": "chrt-aaaa"}],
|
|
}
|
|
)
|
|
)
|
|
|
|
fetched: list[str] = []
|
|
|
|
def stub_fetch(host: str, uid: str) -> dict: # noqa: ARG001
|
|
fetched.append(uid)
|
|
catalogue = {
|
|
"chrt-aaaa": {
|
|
"assetType": "chart",
|
|
"name": "Trade Chart",
|
|
"modifyingViewUid": "data-bbbb",
|
|
},
|
|
"data-bbbb": {
|
|
"assetType": "dataset",
|
|
"name": "Backing Trade Data",
|
|
"columns": [{"fieldName": "x", "dataTypeName": "text"}],
|
|
},
|
|
}
|
|
return catalogue[uid]
|
|
|
|
spec_doc = socrata.build_socrata_spec(
|
|
str(story_path),
|
|
fetch=stub_fetch,
|
|
choice_resolver=_no_choice_resolver,
|
|
)
|
|
# Chart was followed to its dataset — both fetched, the dataset
|
|
# ended up as a top-level command, the chart was skipped.
|
|
assert fetched == ["chrt-aaaa", "data-bbbb"]
|
|
assert "backing_trade_data" in spec_doc["commands"]
|
|
skipped_uids = [s["uid"] for s in spec_doc["_socrata"]["skipped"]]
|
|
assert "chrt-aaaa" in skipped_uids
|
|
|
|
|
|
def test_build_socrata_spec_follows_chart_of_chart_chain_to_dataset(tmp_path):
|
|
"""A chart that points at another chart still resolves — discovery
|
|
is BFS-style, so multi-hop chains land on the underlying dataset."""
|
|
story_path = tmp_path / "story.json"
|
|
story_path.write_text(
|
|
json.dumps(
|
|
{
|
|
"dataSource": {"domainCName": "x.com"},
|
|
"blocks": [{"datasetUid": "chrt-1111"}],
|
|
}
|
|
)
|
|
)
|
|
|
|
def stub_fetch(host: str, uid: str) -> dict: # noqa: ARG001
|
|
return {
|
|
"chrt-1111": {
|
|
"assetType": "chart",
|
|
"name": "Outer chart",
|
|
"modifyingViewUid": "chrt-2222",
|
|
},
|
|
"chrt-2222": {
|
|
"assetType": "chart",
|
|
"name": "Inner chart",
|
|
"modifyingViewUid": "data-3333",
|
|
},
|
|
"data-3333": {
|
|
"assetType": "dataset",
|
|
"name": "Underlying",
|
|
"columns": [{"fieldName": "k", "dataTypeName": "text"}],
|
|
},
|
|
}[uid]
|
|
|
|
spec_doc = socrata.build_socrata_spec(
|
|
str(story_path),
|
|
fetch=stub_fetch,
|
|
choice_resolver=_no_choice_resolver,
|
|
)
|
|
assert "underlying" in spec_doc["commands"]
|
|
skipped_uids = [s["uid"] for s in spec_doc["_socrata"]["skipped"]]
|
|
assert "chrt-1111" in skipped_uids and "chrt-2222" in skipped_uids
|
|
|
|
|
|
def test_build_socrata_spec_does_not_refetch_uid_already_in_story(tmp_path):
|
|
"""If a chart's backing dataset is already referenced by the story,
|
|
the discovery loop notices and skips the redundant fetch — no
|
|
duplicate command entries."""
|
|
story_path = tmp_path / "story.json"
|
|
story_path.write_text(
|
|
json.dumps(
|
|
{
|
|
"dataSource": {"domainCName": "x.com"},
|
|
"blocks": [
|
|
{"datasetUid": "chrt-aaaa"},
|
|
{"datasetUid": "data-bbbb"}, # already referenced directly
|
|
],
|
|
}
|
|
)
|
|
)
|
|
|
|
fetched: list[str] = []
|
|
|
|
def stub_fetch(host: str, uid: str) -> dict: # noqa: ARG001
|
|
fetched.append(uid)
|
|
return {
|
|
"chrt-aaaa": {
|
|
"assetType": "chart",
|
|
"modifyingViewUid": "data-bbbb",
|
|
},
|
|
"data-bbbb": {
|
|
"assetType": "dataset",
|
|
"name": "Trade Data",
|
|
"columns": [{"fieldName": "x", "dataTypeName": "text"}],
|
|
},
|
|
}[uid]
|
|
|
|
spec_doc = socrata.build_socrata_spec(
|
|
str(story_path),
|
|
fetch=stub_fetch,
|
|
choice_resolver=_no_choice_resolver,
|
|
)
|
|
# ``data-bbbb`` fetched only once, not twice.
|
|
assert fetched.count("data-bbbb") == 1
|
|
assert list(spec_doc["commands"]) == ["trade_data"]
|
|
|
|
|
|
def test_build_socrata_spec_skips_chart_with_no_backing_reference(tmp_path):
|
|
"""A chart with neither ``modifyingViewUid`` nor ``query.viewSourceId``
|
|
just gets skipped — there's nowhere to follow."""
|
|
story_path = tmp_path / "story.json"
|
|
story_path.write_text(
|
|
json.dumps(
|
|
{
|
|
"dataSource": {"domainCName": "x.com"},
|
|
"blocks": [{"datasetUid": "chrt-orph"}],
|
|
}
|
|
)
|
|
)
|
|
|
|
def stub_fetch(host: str, uid: str) -> dict: # noqa: ARG001
|
|
return {"assetType": "chart", "name": "Orphan chart"}
|
|
|
|
spec_doc = socrata.build_socrata_spec(
|
|
str(story_path),
|
|
fetch=stub_fetch,
|
|
choice_resolver=_no_choice_resolver,
|
|
)
|
|
assert spec_doc["commands"] == {}
|
|
assert spec_doc["_socrata"]["skipped"] == [
|
|
{"uid": "chrt-orph", "asset_type": "chart"}
|
|
]
|
|
|
|
|
|
# --- _direct_dataset_seed ---
|
|
|
|
|
|
def test_direct_dataset_seed_recognizes_resource_url():
|
|
"""``/resource/{uid}.json`` URL → ``(host, uid)``."""
|
|
assert socrata._direct_dataset_seed(
|
|
"https://agtransport.usda.gov/resource/8bgf-5mdv.json"
|
|
) == ("https://agtransport.usda.gov", "8bgf-5mdv")
|
|
|
|
|
|
def test_direct_dataset_seed_recognizes_api_views_url():
|
|
"""``/api/views/{uid}.json`` URL → ``(host, uid)``."""
|
|
assert socrata._direct_dataset_seed(
|
|
"https://agtransport.usda.gov/api/views/8bgf-5mdv.json"
|
|
) == ("https://agtransport.usda.gov", "8bgf-5mdv")
|
|
|
|
|
|
def test_direct_dataset_seed_works_without_json_suffix():
|
|
assert socrata._direct_dataset_seed(
|
|
"https://agtransport.usda.gov/resource/8bgf-5mdv"
|
|
) == ("https://agtransport.usda.gov", "8bgf-5mdv")
|
|
|
|
|
|
def test_direct_dataset_seed_returns_none_for_story_url():
|
|
"""Story URLs go through the regular story-fetch path."""
|
|
assert (
|
|
socrata._direct_dataset_seed("https://agtransport.usda.gov/stories/s/abcd-1234")
|
|
is None
|
|
)
|
|
|
|
|
|
def test_direct_dataset_seed_returns_none_for_local_path():
|
|
assert socrata._direct_dataset_seed("/tmp/story.json") is None
|
|
|
|
|
|
def test_build_socrata_spec_handles_direct_resource_url(tmp_path):
|
|
"""``--socrata-story <resource-url>`` builds a one-dataset spec
|
|
without trying to parse the URL as a story."""
|
|
|
|
def stub_fetch(host, uid): # noqa: ARG001
|
|
return {
|
|
"assetType": "dataset",
|
|
"name": "Fertilizer Prices by Region",
|
|
"columns": [{"fieldName": "region", "dataTypeName": "text"}],
|
|
}
|
|
|
|
spec_doc = socrata.build_socrata_spec(
|
|
"https://agtransport.usda.gov/resource/8bgf-5mdv.json",
|
|
fetch=stub_fetch,
|
|
choice_resolver=_no_choice_resolver,
|
|
)
|
|
SpecDocument.model_validate(spec_doc)
|
|
assert spec_doc["base_url"] == "https://agtransport.usda.gov"
|
|
# Single dataset → top-level command (no router wrapper).
|
|
assert list(spec_doc["commands"]) == ["fertilizer_prices_by_region"]
|
|
cmd = spec_doc["commands"]["fertilizer_prices_by_region"]
|
|
assert cmd["url_path"] == "/resource/8bgf-5mdv.json"
|
|
|
|
|
|
# --- _normalize_story_url ---
|
|
|
|
|
|
def test_normalize_story_url_appends_json_to_browser_form():
|
|
"""User pastes the human-facing URL; we append ``.json`` so the
|
|
fetcher hits the JSON sibling, not the HTML page."""
|
|
assert (
|
|
socrata._normalize_story_url("https://agtransport.usda.gov/stories/s/7vku-v3nn")
|
|
== "https://agtransport.usda.gov/stories/s/7vku-v3nn.json"
|
|
)
|
|
|
|
|
|
def test_normalize_story_url_preserves_already_json_form():
|
|
url = "https://agtransport.usda.gov/stories/s/7vku-v3nn.json"
|
|
assert socrata._normalize_story_url(url) == url
|
|
|
|
|
|
def test_normalize_story_url_inserts_json_before_query_string():
|
|
"""``?theme=dark`` style query strings on the browser URL stay intact
|
|
after the ``.json`` suffix lands."""
|
|
out = socrata._normalize_story_url(
|
|
"https://agtransport.usda.gov/stories/s/7vku-v3nn?theme=dark"
|
|
)
|
|
assert out == ("https://agtransport.usda.gov/stories/s/7vku-v3nn.json?theme=dark")
|
|
|
|
|
|
def test_normalize_story_url_passes_through_non_story_urls():
|
|
assert (
|
|
socrata._normalize_story_url("https://example.com/api/views/x.json")
|
|
== "https://example.com/api/views/x.json"
|
|
)
|
|
|
|
|
|
def test_normalize_story_url_passes_through_local_path():
|
|
"""File paths don't get the ``.json`` rewrite — they're passed straight
|
|
to ``Path(...).read_text``."""
|
|
assert socrata._normalize_story_url("/local/story.json") == "/local/story.json"
|
|
|
|
|
|
def test_normalize_story_url_leaves_story_subpath_alone():
|
|
"""``/stories/s/<id>/embed`` and similar story-extension paths shouldn't
|
|
get a ``.json`` rewrite — only the bare browser URL does."""
|
|
url = "https://x.com/stories/s/abcd-1234/embed"
|
|
assert socrata._normalize_story_url(url) == url
|
|
|
|
|
|
# --- _read_json_url_or_path ---
|
|
|
|
|
|
def test_aload_story_loads_local_file(tmp_path):
|
|
"""File paths bypass HTTP entirely — read straight off disk."""
|
|
p = tmp_path / "story.json"
|
|
p.write_text(json.dumps({"a": 1}))
|
|
|
|
async def go():
|
|
async with httpx.AsyncClient() as client:
|
|
return await socrata._aload_story(client, str(p))
|
|
|
|
assert asyncio.run(go()) == {"a": 1}
|
|
|
|
|
|
# --- _build_routers / _build_reference ---
|
|
|
|
|
|
def test_build_routers_marks_each_namespace_as_menu_and_leaf_as_command():
|
|
out = socrata._build_routers({"alpha.query": {}, "beta_two.query": {}})
|
|
assert out == {
|
|
"alpha": "menu",
|
|
"alpha.query": "command",
|
|
"beta_two": "menu",
|
|
"beta_two.query": "command",
|
|
}
|
|
|
|
|
|
def test_build_reference_emits_slash_paths_and_routers():
|
|
out = socrata._build_reference({"alpha.query": {"description": "Alpha rows."}})
|
|
assert "/alpha/query" in out["paths"]
|
|
assert out["paths"]["/alpha/query"]["description"] == "Alpha rows."
|
|
assert "/alpha/" in out["routers"]
|
|
|
|
|
|
# --- build_socrata_spec end-to-end ---
|
|
|
|
|
|
def _stub_dataset_meta(host: str, uid: str) -> dict[str, Any]: # noqa: ARG001
|
|
"""Per-uid metadata stub used by the integration-shaped tests below.
|
|
|
|
The catalogue mirrors the agtransport story shape: three datasets that
|
|
share a ``port_profiles_by_*`` prefix (so they cluster into one router)
|
|
and one standalone ``waterborne_*`` dataset (its own router).
|
|
"""
|
|
catalogue = {
|
|
"wbrn-aaaa": {
|
|
"assetType": "dataset",
|
|
"name": "Waterborne Agricultural Trade Data",
|
|
"description": "Imports/exports by commodity.",
|
|
"columns": [{"fieldName": "year", "dataTypeName": "text"}],
|
|
},
|
|
"port-cmdy": {
|
|
"assetType": "dataset",
|
|
"name": "Port Profiles by Commodity",
|
|
"columns": [{"fieldName": "port", "dataTypeName": "text"}],
|
|
},
|
|
"port-pcty": {
|
|
"assetType": "dataset",
|
|
"name": "Port Profiles by Partner Country",
|
|
"columns": [{"fieldName": "port", "dataTypeName": "text"}],
|
|
},
|
|
"port-shtp": {
|
|
"assetType": "dataset",
|
|
"name": "Port Profiles by Shipment Type",
|
|
"columns": [{"fieldName": "port", "dataTypeName": "text"}],
|
|
},
|
|
"wxyz-chrt": {
|
|
# Chart view — should be skipped.
|
|
"assetType": "chart",
|
|
"name": "Sales chart",
|
|
},
|
|
}
|
|
return catalogue[uid]
|
|
|
|
|
|
def test_build_socrata_spec_round_trips_through_spec_document_validator(
|
|
tmp_path, monkeypatch
|
|
):
|
|
story_path = tmp_path / "story.json"
|
|
story_path.write_text(
|
|
json.dumps(
|
|
{
|
|
"uid": "test-stry",
|
|
"dataSource": {"domainCName": "portal.example.com"},
|
|
"blocks": [
|
|
{"datasetUid": "wbrn-aaaa"},
|
|
{"datasetUid": "port-cmdy"},
|
|
{"datasetUid": "port-pcty"},
|
|
{"datasetUid": "port-shtp"},
|
|
{"datasetUid": "wxyz-chrt"},
|
|
],
|
|
}
|
|
)
|
|
)
|
|
spec_doc = socrata.build_socrata_spec(
|
|
str(story_path),
|
|
fetch=_stub_dataset_meta,
|
|
choice_resolver=_no_choice_resolver,
|
|
)
|
|
# Validates against the canonical SpecDocument schema — proves the
|
|
# output matches what ``load_spec`` will accept downstream.
|
|
SpecDocument.model_validate(spec_doc)
|
|
|
|
assert spec_doc["base_url"] == "https://portal.example.com"
|
|
assert spec_doc["api_prefix"] == ""
|
|
assert spec_doc["source_url"] == str(story_path)
|
|
|
|
# Four datasets kept (chart skipped). The singleton
|
|
# ``waterborne_agricultural_trade_data`` is a top-level command
|
|
# (no router); the three ``port_profiles_by_*`` collapse into a
|
|
# single ``port_profiles`` router with one command each. The
|
|
# trailing ``by`` flows back into the command name.
|
|
assert set(spec_doc["commands"]) == {
|
|
"waterborne_agricultural_trade_data",
|
|
"port_profiles.by_commodity",
|
|
"port_profiles.by_partner_country",
|
|
"port_profiles.by_shipment_type",
|
|
}
|
|
assert spec_doc["_socrata"]["dataset_count"] == 4
|
|
assert spec_doc["_socrata"]["skipped"] == [
|
|
{"uid": "wxyz-chrt", "asset_type": "chart"}
|
|
]
|
|
|
|
# Only ``port_profiles`` is a router (the other dataset is a
|
|
# top-level command, not a one-command router).
|
|
router_namespaces = {k for k, v in spec_doc["routers"].items() if v == "menu"}
|
|
assert router_namespaces == {"port_profiles"}
|
|
|
|
|
|
def test_build_socrata_spec_uses_host_override_when_supplied(tmp_path):
|
|
"""Override skips ``_story_host`` so a captured story can drive
|
|
generation against a test mirror without touching the original host."""
|
|
story_path = tmp_path / "story.json"
|
|
story_path.write_text(
|
|
json.dumps(
|
|
{
|
|
"dataSource": {"domainCName": "real.portal.com"},
|
|
"blocks": [{"datasetUid": "wbrn-aaaa"}],
|
|
}
|
|
)
|
|
)
|
|
spec_doc = socrata.build_socrata_spec(
|
|
str(story_path),
|
|
host_override="https://test.mirror",
|
|
fetch=_stub_dataset_meta,
|
|
choice_resolver=_no_choice_resolver,
|
|
)
|
|
assert spec_doc["base_url"] == "https://test.mirror"
|
|
|
|
|
|
def test_build_socrata_spec_handles_story_with_zero_referenced_datasets(tmp_path):
|
|
"""Empty-narrative stories produce a valid (empty) spec rather than
|
|
erroring — caller can decide whether that's a problem."""
|
|
story_path = tmp_path / "empty.json"
|
|
story_path.write_text(
|
|
json.dumps({"dataSource": {"domainCName": "x.com"}, "blocks": []})
|
|
)
|
|
spec_doc = socrata.build_socrata_spec(str(story_path), fetch=lambda h, u: {})
|
|
assert spec_doc["commands"] == {}
|
|
assert spec_doc["routers"] == {}
|
|
SpecDocument.model_validate(spec_doc)
|
|
|
|
|
|
def test_build_socrata_spec_command_url_path_carries_dataset_uid(tmp_path):
|
|
story_path = tmp_path / "story.json"
|
|
story_path.write_text(
|
|
json.dumps(
|
|
{
|
|
"dataSource": {"domainCName": "x.com"},
|
|
"blocks": [{"datasetUid": "wbrn-aaaa"}],
|
|
}
|
|
)
|
|
)
|
|
spec_doc = socrata.build_socrata_spec(
|
|
str(story_path),
|
|
fetch=_stub_dataset_meta,
|
|
choice_resolver=_no_choice_resolver,
|
|
)
|
|
cmd = spec_doc["commands"]["waterborne_agricultural_trade_data"]
|
|
assert cmd["url_path"] == "/resource/wbrn-aaaa.json"
|
|
assert cmd["method"] == "get"
|
|
# Response schema reflects the column metadata.
|
|
items = cmd["response_schema"]["properties"]["results"]["items"]
|
|
assert items["properties"]["year"] == {"type": "string"}
|
|
|
|
|
|
# --- _common_token_prefix / _assign_router_namespaces ---
|
|
|
|
|
|
def test_common_token_prefix_returns_shared_leading_tokens():
|
|
out = socrata._common_token_prefix(
|
|
[
|
|
["port", "profiles", "by", "commodity"],
|
|
["port", "profiles", "by", "country"],
|
|
["port", "profiles", "by", "shipment"],
|
|
]
|
|
)
|
|
assert out == ["port", "profiles", "by"]
|
|
|
|
|
|
def test_common_token_prefix_returns_empty_when_no_input():
|
|
assert socrata._common_token_prefix([]) == []
|
|
|
|
|
|
def test_common_token_prefix_stops_at_first_diverging_token():
|
|
out = socrata._common_token_prefix([["a", "b", "c"], ["a", "b", "d"], ["a", "x"]])
|
|
assert out == ["a"]
|
|
|
|
|
|
def test_assign_router_namespaces_consolidates_shared_prefix_into_one_router():
|
|
"""Three datasets sharing ``port_profiles_by_*`` collapse to one
|
|
router (``port_profiles`` — the trailing ``by`` flows back into the
|
|
command name as ``by_commodity`` etc.)."""
|
|
out = socrata._assign_router_namespaces(
|
|
{
|
|
"uid1": "port_profiles_by_commodity",
|
|
"uid2": "port_profiles_by_partner_country",
|
|
"uid3": "port_profiles_by_shipment_type",
|
|
}
|
|
)
|
|
assert out == {
|
|
"uid1": ("port_profiles", "by_commodity"),
|
|
"uid2": ("port_profiles", "by_partner_country"),
|
|
"uid3": ("port_profiles", "by_shipment_type"),
|
|
}
|
|
|
|
|
|
def test_assign_router_namespaces_singleton_dataset_uses_no_router():
|
|
"""A bucket with one dataset is a top-level command — no router
|
|
wrapper, so the user calls ``waterborne_agricultural_trade_data(...)``
|
|
directly instead of navigating into a one-command menu."""
|
|
out = socrata._assign_router_namespaces(
|
|
{"uid1": "waterborne_agricultural_trade_data"}
|
|
)
|
|
assert out == {"uid1": (None, "waterborne_agricultural_trade_data")}
|
|
|
|
|
|
def test_assign_router_namespaces_strips_trailing_preposition_from_router():
|
|
"""``Sales by *`` collapses to router ``sales`` (not ``sales_by``)."""
|
|
out = socrata._assign_router_namespaces(
|
|
{"u1": "sales_by_region", "u2": "sales_by_quarter"}
|
|
)
|
|
assert out == {
|
|
"u1": ("sales", "by_region"),
|
|
"u2": ("sales", "by_quarter"),
|
|
}
|
|
|
|
|
|
def test_assign_router_namespaces_does_not_cluster_on_single_stop_token():
|
|
"""A single-token shared run that's a stop word (``by``) doesn't
|
|
qualify as a meaningful cluster — both stay as top-level commands."""
|
|
out = socrata._assign_router_namespaces({"u1": "by_alpha", "u2": "by_beta"})
|
|
assert out == {
|
|
"u1": (None, "by_alpha"),
|
|
"u2": (None, "by_beta"),
|
|
}
|
|
|
|
|
|
def test_assign_router_namespaces_does_not_cluster_on_single_token_overlap():
|
|
"""One shared token isn't enough to cluster — generic single-word
|
|
overlaps (``alpha``) would over-bucket otherwise unrelated names."""
|
|
out = socrata._assign_router_namespaces({"u1": "alpha", "u2": "alpha_two"})
|
|
assert out == {
|
|
"u1": (None, "alpha"),
|
|
"u2": (None, "alpha_two"),
|
|
}
|
|
|
|
|
|
def test_assign_router_namespaces_clusters_on_shared_substring_anywhere():
|
|
"""Three datasets sharing ``container_vessel_fleet`` cluster on
|
|
that substring even when none of them start with it. The router
|
|
becomes the shared run; each command keeps the tokens that lie
|
|
outside it."""
|
|
out = socrata._assign_router_namespaces(
|
|
{
|
|
"u1": "idle_container_vessel_fleet",
|
|
"u2": "container_vessel_fleet_data",
|
|
"u3": "global_container_vessel_fleet_and_spot_rates",
|
|
}
|
|
)
|
|
routers = {router for router, _ in out.values()}
|
|
assert routers == {"container_vessel_fleet"}
|
|
by_uid = {uid: cmd for uid, (_, cmd) in out.items()}
|
|
assert by_uid["u1"] == "idle"
|
|
assert by_uid["u2"] == "data"
|
|
# ``and`` survives in the command name — the strip-stop-tokens pass
|
|
# only trims the *router* edges, not internal command tokens.
|
|
assert by_uid["u3"] == "global_and_spot_rates"
|
|
|
|
|
|
# --- _column_choices / _column_help ---
|
|
|
|
|
|
def test_column_choices_uses_resolver_when_host_and_uid_supplied():
|
|
"""Live SoQL resolver is the source of truth for choices when present."""
|
|
out = socrata._column_choices(
|
|
{"fieldName": "x"},
|
|
host="https://h",
|
|
uid="abcd-1234",
|
|
resolver=lambda h, u, f, **k: ["A", "B", "C"], # noqa: ARG005
|
|
)
|
|
assert out == ["A", "B", "C"]
|
|
|
|
|
|
def test_column_choices_falls_back_to_cached_top_when_no_host_uid():
|
|
"""Without a host/uid (unit-test path) the cached top is the source."""
|
|
out = socrata._column_choices(
|
|
{
|
|
"fieldName": "x",
|
|
"cachedContents": {"top": [{"item": "a"}, {"item": "b"}]},
|
|
}
|
|
)
|
|
assert out == ["a", "b"]
|
|
|
|
|
|
def test_column_choices_falls_back_to_cached_top_when_resolver_is_none():
|
|
"""Host + uid + field present but no resolver injected → fall back to
|
|
the metadata's cached top rather than dereferencing the dropped
|
|
sync ``fetch_column_distinct_values`` symbol. Live discovery uses
|
|
the async path via ``_abuild_discovery``; this branch only fires
|
|
when a caller side-steps that pipeline.
|
|
"""
|
|
out = socrata._column_choices(
|
|
{
|
|
"fieldName": "x",
|
|
"cachedContents": {"top": [{"item": "a"}, {"item": "b"}]},
|
|
},
|
|
host="https://h",
|
|
uid="uid-1",
|
|
resolver=None,
|
|
)
|
|
assert out == ["a", "b"]
|
|
|
|
|
|
def test_column_choices_returns_empty_when_resolver_overflow_and_top_at_cap():
|
|
"""Resolver returns ``None`` (overflow) AND cached top is at the cap →
|
|
empty choices. We don't pretend the top-20 is a complete set when we
|
|
know there's more."""
|
|
out = socrata._column_choices(
|
|
{
|
|
"fieldName": "x",
|
|
"cachedContents": {"top": [{"item": f"v{i}"} for i in range(20)]},
|
|
},
|
|
host="https://h",
|
|
uid="uid-1",
|
|
resolver=lambda h, u, f, **k: None, # noqa: ARG005
|
|
)
|
|
assert out == []
|
|
|
|
|
|
def test_column_choices_falls_back_when_resolver_overflow_and_top_below_cap():
|
|
"""Resolver returns ``None`` but cached top is below cap → use cached
|
|
top as a best-effort fallback."""
|
|
out = socrata._column_choices(
|
|
{
|
|
"fieldName": "x",
|
|
"cachedContents": {"top": [{"item": "a"}, {"item": "b"}]},
|
|
},
|
|
host="https://h",
|
|
uid="uid-1",
|
|
resolver=lambda h, u, f, **k: None, # noqa: ARG005
|
|
)
|
|
assert out == ["a", "b"]
|
|
|
|
|
|
def test_column_choices_coerces_numeric_string_values_to_int():
|
|
"""Socrata serializes numeric columns as strings (``"2024"``) — the
|
|
Literal set we generate has to match the declared field type
|
|
(``int`` / ``float``) or callers passing ``year=2024`` would be
|
|
rejected against a ``Literal["2024", ...]`` of strings."""
|
|
out = socrata._column_choices(
|
|
{"fieldName": "year", "dataTypeName": "number"},
|
|
host="https://h",
|
|
uid="uid-1",
|
|
resolver=lambda h, u, f, **k: ["2024", "2023", "2022"], # noqa: ARG005
|
|
)
|
|
assert out == [2024, 2023, 2022]
|
|
|
|
|
|
def test_column_choices_keeps_float_for_non_integer_numeric_values():
|
|
out = socrata._column_choices(
|
|
{"fieldName": "ratio", "dataTypeName": "number"},
|
|
host="https://h",
|
|
uid="uid-1",
|
|
resolver=lambda h, u, f, **k: ["1.5", "2.5"], # noqa: ARG005
|
|
)
|
|
assert out == [1.5, 2.5]
|
|
|
|
|
|
def test_column_choices_drops_uncoercible_numeric_values():
|
|
"""A stray non-numeric string in a numeric column gets silently
|
|
dropped from choices — better than crashing or rejecting valid
|
|
inputs against a Literal contaminated with garbage."""
|
|
out = socrata._column_choices(
|
|
{"fieldName": "year", "dataTypeName": "number"},
|
|
host="https://h",
|
|
uid="uid-1",
|
|
resolver=lambda h, u, f, **k: ["2024", "N/A", "2025"], # noqa: ARG005
|
|
)
|
|
assert out == [2024, 2025]
|
|
|
|
|
|
def test_column_choices_caps_numeric_columns_at_lower_limit():
|
|
"""Numeric columns hit a tighter cap so continuous-valued fields
|
|
(prices, ratios) overflow — only narrow numeric enums (months,
|
|
years) survive as Literal choices."""
|
|
seen_limits: list[int] = []
|
|
|
|
def capture_resolver(host, uid, field, *, limit): # noqa: ARG001
|
|
seen_limits.append(limit)
|
|
|
|
socrata._column_choices(
|
|
{"fieldName": "fuel_price", "dataTypeName": "number"},
|
|
host="https://h",
|
|
uid="uid-1",
|
|
resolver=capture_resolver,
|
|
)
|
|
socrata._column_choices(
|
|
{"fieldName": "port", "dataTypeName": "text"},
|
|
host="https://h",
|
|
uid="uid-1",
|
|
resolver=capture_resolver,
|
|
)
|
|
# Numeric column probed with the tight cap (50); text column with
|
|
# the wider one (1000).
|
|
assert seen_limits == [
|
|
socrata._NUMERIC_CHOICE_CAP,
|
|
socrata._MAX_RESOLVED_CHOICES,
|
|
]
|
|
|
|
|
|
def test_column_choices_returns_empty_when_no_field_name():
|
|
"""Defensive: a column with no ``fieldName`` can't be queried."""
|
|
out = socrata._column_choices(
|
|
{},
|
|
host="https://h",
|
|
uid="uid-1",
|
|
resolver=lambda h, u, f: ["should-not-be-called"], # noqa: ARG005
|
|
)
|
|
assert out == []
|
|
|
|
|
|
def test_cached_top_values_extracts_items_and_skips_malformed_entries():
|
|
"""Defensive: stray non-dict entries don't crash the extractor."""
|
|
out = socrata._cached_top_values(
|
|
{"cachedContents": {"top": [{"item": "ok"}, {"count": 5}, "bareString", None]}}
|
|
)
|
|
assert out == ["ok", "bareString"]
|
|
|
|
|
|
def test_cached_top_values_returns_empty_when_no_cached_contents():
|
|
assert socrata._cached_top_values({"fieldName": "x"}) == []
|
|
|
|
|
|
# --- fetch_column_distinct_values ---
|
|
|
|
|
|
def test_afetch_column_distinct_values_builds_soql_query():
|
|
"""SoQL probe issues ``$select=col&$group=col&$order=col&$limit=N+1``."""
|
|
captured: dict[str, str] = {}
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
captured["url"] = str(request.url)
|
|
return httpx.Response(200, json=[{"port": "A"}, {"port": "B"}])
|
|
|
|
out = _run_with_mock_client(
|
|
handler,
|
|
lambda c: socrata.afetch_column_distinct_values(
|
|
c, "https://portal", "abcd-1234", "port", limit=1000
|
|
),
|
|
)
|
|
assert out == ["A", "B"]
|
|
assert "/resource/abcd-1234.json" in captured["url"]
|
|
# ``$``-prefixed SoQL params survive as URL-encoded ``%24``.
|
|
assert "%24select=port" in captured["url"]
|
|
assert "%24group=port" in captured["url"]
|
|
assert "%24limit=1001" in captured["url"] # limit + 1 for overflow detection
|
|
|
|
|
|
def test_afetch_column_distinct_values_returns_none_on_overflow():
|
|
"""Response with ``limit + 1`` rows signals overflow → no choices."""
|
|
|
|
def handler(_request: httpx.Request) -> httpx.Response:
|
|
return httpx.Response(200, json=[{"port": f"P{i}"} for i in range(11)])
|
|
|
|
out = _run_with_mock_client(
|
|
handler,
|
|
lambda c: socrata.afetch_column_distinct_values(
|
|
c, "https://x", "uid-1", "port", limit=10
|
|
),
|
|
)
|
|
assert out is None
|
|
|
|
|
|
def test_afetch_column_distinct_values_returns_none_on_network_error():
|
|
"""Transient network failures fall through to ``None`` so the caller
|
|
can degrade gracefully (cached-top fallback) instead of crashing."""
|
|
|
|
def handler(_request: httpx.Request) -> httpx.Response:
|
|
raise httpx.ConnectError("dns failed")
|
|
|
|
out = _run_with_mock_client(
|
|
handler,
|
|
lambda c: socrata.afetch_column_distinct_values(
|
|
c, "https://x", "uid-1", "port"
|
|
),
|
|
)
|
|
assert out is None
|
|
|
|
|
|
def test_afetch_column_distinct_values_skips_rows_without_field_value():
|
|
"""A row with the column missing or null is dropped from the choices."""
|
|
|
|
def handler(_request: httpx.Request) -> httpx.Response:
|
|
return httpx.Response(
|
|
200,
|
|
json=[{"port": "A"}, {"other": "x"}, {"port": None}, {"port": "B"}],
|
|
)
|
|
|
|
out = _run_with_mock_client(
|
|
handler,
|
|
lambda c: socrata.afetch_column_distinct_values(
|
|
c, "https://x", "uid-1", "port"
|
|
),
|
|
)
|
|
assert out == ["A", "B"]
|
|
|
|
|
|
def test_afetch_column_distinct_values_returns_none_when_payload_not_a_list():
|
|
"""Defensive: an error response (object) shouldn't masquerade as choices."""
|
|
|
|
def handler(_request: httpx.Request) -> httpx.Response:
|
|
return httpx.Response(200, json={"error": "rate limit"})
|
|
|
|
out = _run_with_mock_client(
|
|
handler,
|
|
lambda c: socrata.afetch_column_distinct_values(
|
|
c, "https://x", "uid-1", "port"
|
|
),
|
|
)
|
|
assert out is None
|
|
|
|
|
|
def test_column_help_combines_description_and_examples_for_capped_columns():
|
|
help_text = socrata._column_help(
|
|
{
|
|
"description": "The U.S. port of entry",
|
|
"cachedContents": {
|
|
"top": [{"item": f"PORT_{i}"} for i in range(20)],
|
|
"smallest": "AAA",
|
|
"largest": "ZZZ",
|
|
},
|
|
}
|
|
)
|
|
assert "U.S. port of entry." in help_text
|
|
assert "Examples:" in help_text
|
|
assert "PORT_0" in help_text
|
|
assert "AAA" in help_text and "ZZZ" in help_text
|
|
|
|
|
|
def test_column_help_omits_range_when_choices_present():
|
|
"""A constrained-choices column already lists every value — adding a
|
|
range hint would be redundant."""
|
|
help_text = socrata._column_help(
|
|
{
|
|
"description": "Yes/no flag",
|
|
"cachedContents": {
|
|
"top": [{"item": "Yes"}, {"item": "No"}],
|
|
"smallest": "No",
|
|
"largest": "Yes",
|
|
},
|
|
}
|
|
)
|
|
assert "Range:" not in help_text
|
|
|
|
|
|
def test_column_help_returns_empty_when_no_metadata_to_summarize():
|
|
assert socrata._column_help({"fieldName": "bare"}) == ""
|
|
|
|
|
|
# --- afetch_dataset_metadata / _aload_story (network-mocked) ---
|
|
|
|
|
|
def test_afetch_dataset_metadata_hits_api_views_endpoint():
|
|
"""The fetcher targets ``{host}/api/views/{uid}.json`` exactly."""
|
|
captured: dict[str, str] = {}
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
captured["url"] = str(request.url)
|
|
return httpx.Response(200, json={"name": "ok", "columns": []})
|
|
|
|
out = _run_with_mock_client(
|
|
handler,
|
|
lambda c: socrata.afetch_dataset_metadata(
|
|
c, "https://portal.example", "abcd-9999"
|
|
),
|
|
)
|
|
assert captured["url"] == "https://portal.example/api/views/abcd-9999.json"
|
|
assert out == {"name": "ok", "columns": []}
|
|
|
|
|
|
def test_aload_story_loads_remote_url():
|
|
"""HTTP URLs go through the shared ``httpx.AsyncClient``."""
|
|
|
|
def handler(_request: httpx.Request) -> httpx.Response:
|
|
return httpx.Response(200, json={"k": "v"})
|
|
|
|
out = _run_with_mock_client(
|
|
handler,
|
|
lambda c: socrata._aload_story(c, "https://x/story.json"),
|
|
)
|
|
assert out == {"k": "v"}
|
|
|
|
|
|
# --- Helper-function coverage gaps ---
|
|
|
|
|
|
def test_detect_time_axis_column_skips_non_dict_columns_in_both_passes():
|
|
"""Both the calendar-date pass and the time-axis-named fallback skip
|
|
non-dict entries — defensive against malformed metadata."""
|
|
meta = {
|
|
"columns": [
|
|
"not-a-dict", # skipped in pass 1
|
|
{"fieldName": "x", "dataTypeName": "text"}, # not a date, not time-named
|
|
"still-not-a-dict", # skipped in pass 2
|
|
{"fieldName": "year", "dataTypeName": "text"}, # time-named text col
|
|
]
|
|
}
|
|
assert socrata._detect_time_axis_column(meta) == "year"
|
|
|
|
|
|
def test_detect_time_axis_column_returns_none_when_no_columns_qualify():
|
|
"""Without any date-typed or time-axis-named columns,
|
|
``_detect_time_axis_column`` returns ``None`` so downstream code knows
|
|
there's nothing to sort by."""
|
|
meta = {
|
|
"columns": [
|
|
{"fieldName": "x", "dataTypeName": "text"},
|
|
{"fieldName": "y", "dataTypeName": "number"},
|
|
]
|
|
}
|
|
assert socrata._detect_time_axis_column(meta) is None
|
|
|
|
|
|
def test_longest_common_token_run_returns_empty_for_no_common_run():
|
|
"""Three lists with no shared run of tokens → empty list."""
|
|
assert socrata._longest_common_token_run([["a", "b"], ["c", "d"], ["e", "f"]]) == []
|
|
|
|
|
|
def test_longest_common_token_run_returns_empty_for_empty_input():
|
|
"""Empty input list short-circuits to ``[]`` — exercised by the
|
|
``if not token_lists`` guard."""
|
|
assert socrata._longest_common_token_run([]) == []
|
|
|
|
|
|
def test_strip_stop_tokens_trims_leading_and_trailing_connectors():
|
|
"""Both the head and tail of the list get stripped of stop tokens."""
|
|
out = socrata._strip_stop_tokens(["of", "the", "vessel", "data", "by", "of"])
|
|
assert out == ["vessel", "data"]
|
|
# All-stop list collapses to empty.
|
|
assert socrata._strip_stop_tokens(["of", "the"]) == []
|
|
|
|
|
|
def test_assign_router_namespaces_falls_through_to_singletons_when_router_strips_to_empty():
|
|
"""When two slugs cluster (shared token run ≥ 2) but the run is
|
|
entirely stop-tokens that strip to nothing, every member falls
|
|
through to a singleton ``(None, slug)`` rather than getting forced
|
|
into an empty router bucket.
|
|
|
|
``of_the_data`` + ``of_the_other`` share ``["of", "the"]`` (length 2 →
|
|
clusters), but stripping connectors leaves an empty router name, so
|
|
both UIDs become top-level commands.
|
|
"""
|
|
slugs = {"u1": "of_the_data", "u2": "of_the_other"}
|
|
out = socrata._assign_router_namespaces(slugs)
|
|
# Both fell through to singletons — no synthetic empty-name router.
|
|
assert out["u1"] == (None, "of_the_data")
|
|
assert out["u2"] == (None, "of_the_other")
|
|
|
|
|
|
def test_assign_router_namespaces_returns_empty_for_empty_input():
|
|
"""No slugs → empty mapping (the trivial early-out path)."""
|
|
assert socrata._assign_router_namespaces({}) == {}
|
|
|
|
|
|
def test_command_tokens_around_run_returns_full_list_when_run_absent():
|
|
"""If the router-token run isn't found in the per-member token list
|
|
(shouldn't happen in practice, but defensive), return the full
|
|
token list unchanged."""
|
|
out = socrata._command_tokens_around_run(["alpha", "beta", "gamma"], ["nope"], 1)
|
|
assert out == ["alpha", "beta", "gamma"]
|
|
|
|
|
|
def test_aread_json_url_or_path_reads_local_file_when_path_supplied(tmp_path):
|
|
"""Non-URL inputs read from the local filesystem — exercises the
|
|
``return json.loads(Path(...).read_text())`` fallback branch."""
|
|
p = tmp_path / "story.json"
|
|
p.write_text(json.dumps({"k": "v"}))
|
|
|
|
async def go():
|
|
return await socrata._aread_json_url_or_path(str(p))
|
|
|
|
assert asyncio.run(go()) == {"k": "v"}
|
|
|
|
|
|
def test_aread_json_url_or_path_fetches_via_http_when_url_supplied():
|
|
"""The URL branch of ``_aread_json_url_or_path`` issues a real-shape
|
|
HTTP request (mocked) rather than touching the local filesystem."""
|
|
captured: dict = {}
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
captured["url"] = str(request.url)
|
|
return httpx.Response(200, json={"ok": True})
|
|
|
|
async def go():
|
|
# Patch httpx.AsyncClient to use the mock transport.
|
|
import openbb_cli.dispatchers.socrata as smod
|
|
|
|
original = smod.httpx.AsyncClient
|
|
|
|
def factory(*args, **kwargs):
|
|
kwargs.pop("transport", None)
|
|
return original(*args, transport=httpx.MockTransport(handler), **kwargs)
|
|
|
|
smod.httpx.AsyncClient = factory
|
|
try:
|
|
return await smod._aread_json_url_or_path("https://h/story.json")
|
|
finally:
|
|
smod.httpx.AsyncClient = original
|
|
|
|
result = asyncio.run(go())
|
|
assert result == {"ok": True}
|
|
assert "https://h/story.json" in captured["url"]
|
|
|
|
|
|
def test_afetch_json_returns_decoded_json_body():
|
|
"""Plain helper: GET, raise for status, return decoded JSON."""
|
|
|
|
def handler(_request):
|
|
return httpx.Response(200, json={"k": "v"})
|
|
|
|
out = _run_with_mock_client(
|
|
handler, lambda c: socrata._afetch_json(c, "https://h/x.json")
|
|
)
|
|
assert out == {"k": "v"}
|
|
|
|
|
|
def test_amaybe_call_passes_through_sync_callable_result():
|
|
"""A non-coroutine return value goes through ``_amaybe_call`` unchanged
|
|
(no awaiting / wrapping)."""
|
|
out = asyncio.run(socrata._amaybe_call(lambda x, y: x + y, 1, 2))
|
|
assert out == 3
|
|
|
|
|
|
def test_amaybe_call_awaits_coroutine_result():
|
|
"""Coroutine return values get awaited so the caller doesn't have to
|
|
branch on whether the stub was sync or async."""
|
|
|
|
async def aplus(x, y):
|
|
return x + y
|
|
|
|
out = asyncio.run(socrata._amaybe_call(aplus, 1, 2))
|
|
assert out == 3
|
|
|
|
|
|
def test_aone_metadata_returns_none_when_default_fetcher_raises_http_error():
|
|
"""Default fetcher path: HTTP errors get caught and yield ``None``
|
|
(the caller records this as "unreachable" so the build keeps going)."""
|
|
|
|
def handler(_request):
|
|
return httpx.Response(404)
|
|
|
|
out = _run_with_mock_client(
|
|
handler,
|
|
lambda c: socrata._aone_metadata(c, "https://h", "uid-1", None),
|
|
)
|
|
assert out is None
|
|
|
|
|
|
def test_aone_metadata_returns_none_when_stub_raises():
|
|
"""Stub fetcher path: any ``(httpx.HTTPError, OSError, ValueError)``
|
|
raised by a test stub gets caught and surfaces as ``None``."""
|
|
|
|
def stub_raises(host, uid): # noqa: ARG001
|
|
raise ValueError("simulated metadata failure")
|
|
|
|
out = _run_with_mock_client(
|
|
lambda r: httpx.Response(200),
|
|
lambda c: socrata._aone_metadata(c, "https://h", "uid-1", stub_raises),
|
|
)
|
|
assert out is None
|
|
|
|
|
|
def test_afetch_metadata_batch_short_circuits_for_empty_uids():
|
|
"""Empty UID list → no work, empty dict back."""
|
|
out = _run_with_mock_client(
|
|
lambda r: httpx.Response(200),
|
|
lambda c: socrata._afetch_metadata_batch(c, "https://h", [], None),
|
|
)
|
|
assert out == {}
|
|
|
|
|
|
def test_aone_choice_returns_none_when_resolver_raises():
|
|
"""Test-stub resolver that raises one of the caught exceptions → ``None``."""
|
|
|
|
def raising_resolver(host, uid, field, *, limit): # noqa: ARG001
|
|
raise httpx.HTTPError("simulated choice probe failure")
|
|
|
|
out = _run_with_mock_client(
|
|
lambda r: httpx.Response(200),
|
|
lambda c: socrata._aone_choice(
|
|
c, "https://h", "uid-1", "field", 10, raising_resolver
|
|
),
|
|
)
|
|
assert out is None
|
|
|
|
|
|
def test_aone_choice_uses_default_async_fetcher_when_resolver_is_none():
|
|
"""No resolver injected → call ``afetch_column_distinct_values``
|
|
directly (the live discovery path). Mocked at the HTTP transport
|
|
level so we exercise the real default-fetcher branch.
|
|
"""
|
|
|
|
def handler(_request):
|
|
# Single-value response — under the cap, so returns the values.
|
|
return httpx.Response(200, json=[{"port": "Tampa"}])
|
|
|
|
out = _run_with_mock_client(
|
|
handler,
|
|
lambda c: socrata._aone_choice(c, "https://h", "uid-1", "port", 10, None),
|
|
)
|
|
assert out == ["Tampa"]
|
|
|
|
|
|
def test_abuild_discovery_records_unreachable_uids_as_skipped(tmp_path):
|
|
"""Stories that reference deleted / private datasets get those UIDs
|
|
recorded as ``"unreachable"`` in the skipped list rather than
|
|
aborting the whole build. Exercises the ``meta is None`` branch in
|
|
``_abuild_discovery``'s wave loop.
|
|
"""
|
|
story = {
|
|
"dataSource": {"domainCName": "x.com"},
|
|
"blocks": [{"datasetUid": "dead-beef"}],
|
|
}
|
|
story_path = tmp_path / "story.json"
|
|
story_path.write_text(json.dumps(story))
|
|
|
|
def stub_fetch(host, uid): # noqa: ARG001
|
|
# Returns ``None`` → the UID is unreachable and gets recorded
|
|
# in ``skipped``.
|
|
return None
|
|
|
|
spec_doc = socrata.build_socrata_spec(
|
|
str(story_path),
|
|
fetch=stub_fetch,
|
|
choice_resolver=_no_choice_resolver,
|
|
)
|
|
# Story produced no commands (nothing reachable) and no errors.
|
|
assert spec_doc["commands"] == {}
|
|
|
|
|
|
def test_aone_choice_uses_default_async_fetcher_when_resolver_is_the_async_function():
|
|
"""Passing the canonical ``afetch_column_distinct_values`` as the
|
|
resolver takes the same default-fetcher branch (not the stub branch),
|
|
since it's identity-checked."""
|
|
|
|
def handler(_request):
|
|
return httpx.Response(200, json=[{"port": "Tampa"}, {"port": "Miami"}])
|
|
|
|
out = _run_with_mock_client(
|
|
handler,
|
|
lambda c: socrata._aone_choice(
|
|
c,
|
|
"https://h",
|
|
"uid-1",
|
|
"port",
|
|
10,
|
|
socrata.afetch_column_distinct_values,
|
|
),
|
|
)
|
|
assert out == ["Tampa", "Miami"]
|
|
|
|
|
|
def test_aprobe_choices_batch_skips_dates_objects_and_returns_empty_for_no_tasks():
|
|
"""Date-type and unsupported-type columns are filtered out before the
|
|
probe; no eligible columns → empty cache back."""
|
|
metas = {
|
|
"uid-1": {
|
|
"columns": [
|
|
"not-a-dict", # skipped — non-dict
|
|
{"dataTypeName": "text"}, # skipped — no fieldName
|
|
{"fieldName": "obs_date", "dataTypeName": "calendar_date"}, # date
|
|
{"fieldName": "loc", "dataTypeName": "location"}, # not numeric/text
|
|
]
|
|
}
|
|
}
|
|
out = _run_with_mock_client(
|
|
lambda r: httpx.Response(200),
|
|
lambda c: socrata._aprobe_choices_batch(c, "https://h", metas, None),
|
|
)
|
|
assert out == {}
|
|
|
|
|
|
def test_primary_item_column_picks_highest_cardinality_string_column():
|
|
"""``_primary_item_column`` selects the string param with the most
|
|
distinct choices — the most useful 'item' for ``limit`` semantics."""
|
|
parameters = [
|
|
{"name": "limit", "type": "integer"}, # excluded by name
|
|
{"name": "offset", "type": "integer"}, # excluded by name
|
|
{"name": "obs_date", "type": "string"}, # excluded — time axis
|
|
{"name": "year", "type": "string"}, # excluded — time-axis named
|
|
{
|
|
"name": "start_date",
|
|
"type": "string",
|
|
"_socrata_op": "date_min",
|
|
}, # excluded — date-range op
|
|
{"name": "category", "type": "integer"}, # excluded — not string
|
|
{"name": "boolish", "type": "string", "choices": ["a", "b"]}, # < 3 choices
|
|
{"name": "no_choices", "type": "string"}, # < 3 choices (empty)
|
|
{
|
|
"name": "region",
|
|
"type": "string",
|
|
"choices": ["a", "b", "c"],
|
|
}, # 3 choices
|
|
{
|
|
"name": "port",
|
|
"type": "string",
|
|
"choices": [f"p{i}" for i in range(50)],
|
|
}, # 50 choices — wins
|
|
"not-a-dict", # defensive skip
|
|
]
|
|
out = socrata._primary_item_column(parameters, time_axis="obs_date")
|
|
assert out == "port"
|
|
|
|
|
|
def test_primary_item_column_returns_none_when_no_eligible_column():
|
|
"""No string column with ≥3 choices outside the excluded set → ``None``."""
|
|
assert socrata._primary_item_column([], time_axis=None) is None
|
|
assert (
|
|
socrata._primary_item_column(
|
|
[{"name": "limit", "type": "integer"}], time_axis=None
|
|
)
|
|
is None
|
|
)
|
|
|
|
|
|
def test_column_choices_choices_cache_hit_with_content_returns_coerced():
|
|
"""Cache-hit with non-None contents → coerce via the column's data
|
|
type. Exercises the inner ``return _coerce_choices_to_type(...)``
|
|
branch (was previously uncovered)."""
|
|
out = socrata._column_choices(
|
|
{"fieldName": "active", "dataTypeName": "checkbox"},
|
|
host="https://h",
|
|
uid="uid-1",
|
|
choices_cache={("uid-1", "active"): ["true", "false"]},
|
|
)
|
|
assert out == [True, False]
|