diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ed53a54..7288e02e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,44 @@ All notable changes to `mostlyright`. The format follows [Keep a Changelog](http ## [Unreleased] +## [1.12.1] — 2026-07-08 — #107 follow-ups: cross-SDK consistency + version metadata + +Patch release, dual-SDK. Resolves the P3 follow-ups from the 1.12.0 review +loop (#107). No behavior change on any default path. + +### Fixed + +- **`__version__` now derived from dist metadata** in all three Python + packages via `importlib.metadata` (was hardcoded and stale: core/weather + reported `0.1.0rc1`, markets `0.0.1`, while dists shipped 1.12.x). Falls + back to `0.0.0+unknown` in metadata-less source trees. `DataVersion` + tokens computed via `for_research()` without an explicit `sdk_version` + now reflect the true SDK version (opt-in surface; not wired into + `research()`, no cache/settlement impact). +- **Cross-SDK unknown-source error text unified** on the `research()` + surface: both SDKs now emit `research(): source must be one of [...]`, + exact-message-pinned in both test suites. `researchBySource`'s + `Mode 2 source must be one of` text is unchanged. +- **TS `sources: null` honors the Phase 21 null-as-absent wire contract** + (was throwing the v0.3 deferred error for a JSON-round-tripped Python + `sources=None`); removed a dead TS mutual-exclusion check whose only + reachable firing was that same wire-contract bug, and the mirrored + unreachable Python `ValueError` block. +- **Pinned-source provenance threads explicitly through the wrapper path** + (`wrap_result(source=...)`, mirroring `mode2.research_by_source`) — + defense-in-depth; unpinned wrapper calls byte-identical. + +### Added + +- TS public barrel now re-exports `RESEARCH_SOURCE_ALIASES`, + `RESEARCH_OBSERVATION_SOURCES`, `isResearchObservationSource`. +- Coverage: `research(source=)` under `as_dataframe=False`, polars wrapper + (skip-guarded), empty-after-pin attrs; TS `sources: null` regression; + barrel surface test. +- Weather TS bundle growth root-caused (hosted-shim root-barrel re-export, + a tree-shaking blocker — not data growth); shrink deferred to its own PR + since it contracts the root-barrel API (#107). + ## [1.12.0] — 2026-07-08 — research(source=) pin + Mode 2 window fix Minor release, dual-SDK (PyPI 1.12.0 + npm 1.12.0 in lockstep). diff --git a/packages-ts/core/package.json b/packages-ts/core/package.json index df013116..c82dc83a 100644 --- a/packages-ts/core/package.json +++ b/packages-ts/core/package.json @@ -1,6 +1,6 @@ { "name": "@mostlyrightmd/core", - "version": "1.12.0", + "version": "1.12.1", "description": "TypeScript SDK core for quants, ML pipelines, and AI agents: types, schemas, validators, temporal-safety primitives, and the research() join over weather data + prediction-market settlements. Local-first, no hosted backend.", "keywords": [ "weather", diff --git a/packages-ts/markets/package.json b/packages-ts/markets/package.json index dbe5561e..6582cadc 100644 --- a/packages-ts/markets/package.json +++ b/packages-ts/markets/package.json @@ -1,6 +1,6 @@ { "name": "@mostlyrightmd/markets", - "version": "1.12.0", + "version": "1.12.1", "description": "Prediction-market data for TypeScript / Node — Kalshi NHIGH/NLOW weather-contract resolvers, Polymarket discovery + settlement, and Kalshi + Polymarket trade history. For quants, backtesting, and ML training pipelines.", "keywords": [ "kalshi", diff --git a/packages-ts/meta/package.json b/packages-ts/meta/package.json index 0ae058d3..1a8fd79a 100644 --- a/packages-ts/meta/package.json +++ b/packages-ts/meta/package.json @@ -1,6 +1,6 @@ { "name": "mostlyright", - "version": "1.12.0", + "version": "1.12.1", "description": "Public-data SDK for TypeScript — one import for quants, ML pipelines, and AI agents. Adapters ship weather (METAR, ASOS, GHCNh, NWS CLI) and prediction-market settlements (Kalshi NHIGH/NLOW, Polymarket) today; SEC filings, Federal Reserve series, court filings, FDA approvals, and equities are next. Local-first, no hosted backend.", "keywords": [ "weather", diff --git a/packages-ts/meta/src/index.ts b/packages-ts/meta/src/index.ts index b40fa674..279804c7 100644 --- a/packages-ts/meta/src/index.ts +++ b/packages-ts/meta/src/index.ts @@ -27,11 +27,21 @@ export { research, type ResearchOptions, type PairsRow } from "./research.js"; // package alongside research() — the dispatch needs the @mostlyrightmd/weather // Observation type and assertSourceIdentity consumes it structurally; // @mostlyrightmd/core must NOT depend on weather (cycle). +// +// Also re-exports the WIDENED `research({ source })` filter vocabulary +// (RESEARCH_SOURCE_ALIASES / RESEARCH_OBSERVATION_SOURCES / +// isResearchObservationSource) — the Python-parity superset that +// `research()`'s single-source pin validates against — so callers can +// introspect the accepted names without a deep import, mirroring the +// narrow-dispatch siblings (SOURCE_ALIASES / isMode2Source). export { MODE2_SOURCES, + RESEARCH_OBSERVATION_SOURCES, + RESEARCH_SOURCE_ALIASES, SOURCE_ALIASES, assertSourceIdentity, isMode2Source, + isResearchObservationSource, researchBySource, type Mode2Source, type ResearchBySourceOptions, diff --git a/packages-ts/meta/src/mode2.ts b/packages-ts/meta/src/mode2.ts index 2d950016..c27b5960 100644 --- a/packages-ts/meta/src/mode2.ts +++ b/packages-ts/meta/src/mode2.ts @@ -384,6 +384,10 @@ export async function researchBySource( // outside the caller's window; per-source Mode 2 callers expect rows // strictly inside [fromDate, toDate]. Python mode2.py parity. rows = parsed.filter((r) => { + // `observed_at` is a guaranteed non-empty ISO string on Observation, + // so `.slice(0, 10)` is safe without a runtime guard (same in the + // IEM/GHCNh branches below). Python's `_in_query_window` must drop + // falsy `observed_at` defensively; TS leans on the schema type. const d = r.observed_at.slice(0, 10); return d >= fromDate && d <= toDate; }); diff --git a/packages-ts/meta/src/research.ts b/packages-ts/meta/src/research.ts index 5543a561..46c79db7 100644 --- a/packages-ts/meta/src/research.ts +++ b/packages-ts/meta/src/research.ts @@ -869,15 +869,27 @@ export async function research( if (hasContracts) names.push("contracts"); throw new Error(`research(): selectors are mutually exclusive; got ${JSON.stringify(names)}`); } - if (opts.sources !== undefined && opts.source != null) { - throw new Error("research(): sources and source are mutually exclusive"); - } + // NOTE: the `sources=`/`source=` mutual-exclusion guard that used to live + // here was DEAD and is removed (mirrors the Python parallel cleanup that + // drops the duplicate `sources is not None and source is not None` + // ValueError in research.py). `validateResearchKwargs()` above (~line 822) + // already throws a TypeError for the both-provided combo with nullish + // (`!= null`) presence semantics BEFORE this point — proven by + // research.kwargs.test.ts ("raises TypeError on sources + source + // together"). The old check used `sources !== undefined`, so its ONLY + // reachable firing was `sources: null` + a real `source` — which the wire + // contract treats as `sources` ABSENT, so that firing was itself a bug. + // // `sources` (plural) — the Mode 1 multi-source SUBSET selector — is // validated in Phase 10 v0.2 but its dedupe-within-subset wiring lands in // v0.3; passing it throws. NOTE: `source` (singular) is now FUNCTIONAL // (single-source pin, wired below) — only the multi-source subset remains - // deferred. - if (opts.sources !== undefined) { + // deferred. Presence is nullish (`!= null`), NOT `!== undefined`: per the + // Phase 21 wire contract (research.types.ts `present()` / Python + // `sources is not None`), a JSON-round-tripped Python `sources=None` + // arrives as `null` and must behave as ABSENT — `!== undefined` would + // wrongly throw the v0.3-deferred error for it. + if (opts.sources != null) { throw new Error( "research(): the `sources` multi-source subset selector is validated in " + "Phase 10 v0.2 but its dedupe-within-subset wiring lands in v0.3. For " + @@ -887,15 +899,20 @@ export async function research( } // `source` (singular) — Mode 2 single-source pin. Validate against the // widened Mode 2 vocabulary BEFORE any network fetch so invalid input - // never burns API quota. Mirrors Python `research_by_source`'s ValueError - // guard (packages/core/src/mostlyright/mode2.py:112-115); the accepted - // names are byte-identical across SDKs (RESEARCH_OBSERVATION_SOURCES). + // never burns API quota. Mirrors Python `research()`'s own source guard + // (packages/core/src/mostlyright/research.py:1750-1754): the canonical + // `research(): source must be one of ...` text and the accepted names + // (RESEARCH_OBSERVATION_SOURCES) are byte-identical across SDKs — each SDK + // pins its native quote style via its own stringify (JSON.stringify here, + // Python `repr` there). This is distinct from `researchBySource`'s + // narrower "Mode 2 source must be one of ..." guard (mode2.ts), which + // stays lockstep and is intentionally left untouched. // Presence is nullish (`!= null`), NOT `!== undefined`: per the Phase 21 // wire contract (research.types.ts `present()`), a JSON-round-tripped // Python `source=None` arrives as `null` and must behave as ABSENT. if (opts.source != null && !isResearchObservationSource(opts.source)) { throw new Error( - `Mode 2 source must be one of ${JSON.stringify( + `research(): source must be one of ${JSON.stringify( [...RESEARCH_OBSERVATION_SOURCES].sort(), )}; got ${JSON.stringify(opts.source)}`, ); diff --git a/packages-ts/meta/tests/compose.test.ts b/packages-ts/meta/tests/compose.test.ts index 44b93665..f39e6314 100644 --- a/packages-ts/meta/tests/compose.test.ts +++ b/packages-ts/meta/tests/compose.test.ts @@ -247,7 +247,11 @@ describe("research() Phase 10 signature validation", () => { // covered in research.test.ts. await expect( research("KNYC", "2025-01-06", "2025-01-12", { source: "not-a-real-source" }), - ).rejects.toThrow(/Mode 2 source must be one of/); + ).rejects.toThrow( + // Item 1 (v1.12.1): canonical cross-SDK `research(): source must be one + // of ...` text (each SDK pins its native quote style — JSON here). + 'research(): source must be one of ["awc","awc.live","ghcnh","ghcnh.archive","iem","iem.archive","iem.live"]; got "not-a-real-source"', + ); }); it("stationOverride requires contract", async () => { diff --git a/packages-ts/meta/tests/mode2.barrel.test.ts b/packages-ts/meta/tests/mode2.barrel.test.ts index fb9cd0f9..37e1f3db 100644 --- a/packages-ts/meta/tests/mode2.barrel.test.ts +++ b/packages-ts/meta/tests/mode2.barrel.test.ts @@ -14,10 +14,13 @@ import { describe, expect, it } from "vitest"; import { MODE2_SOURCES, type Mode2Source, + RESEARCH_OBSERVATION_SOURCES, + RESEARCH_SOURCE_ALIASES, SOURCE_ALIASES, type SourceMismatchRole, assertSourceIdentity, isMode2Source, + isResearchObservationSource, researchBySource, } from "../src/index.js"; @@ -45,6 +48,22 @@ describe("meta barrel — TS-W4 Wave 1 Mode 2 surface", () => { expect(isMode2Source("iem")).toBe(false); }); + it("re-exports the widened research({ source }) filter vocabulary (Item 2, v1.12.1)", () => { + // RESEARCH_* is the Python-parity SUPERSET that research()'s single-source + // pin validates against — 7 names = the 4 dotted forms + 3 bare tags. + expect(RESEARCH_OBSERVATION_SOURCES.size).toBe(7); + expect(RESEARCH_SOURCE_ALIASES.size).toBe(7); + // Bare parser tags are accepted here but NOT by isMode2Source (narrower). + expect(isResearchObservationSource("iem")).toBe(true); + expect(isMode2Source("iem")).toBe(false); + // Dotted forms accepted by both. + expect(isResearchObservationSource("iem.archive")).toBe(true); + // Unknown rejected. + expect(isResearchObservationSource("banana")).toBe(false); + // Alias table carries the bare-tag → alias-set rows. + expect(RESEARCH_SOURCE_ALIASES.get("awc")?.has("awc.live")).toBe(true); + }); + it("Mode2Source + SourceMismatchRole types are accessible from the barrel", () => { // Type-level check — assignments compile iff the type re-exports are wired. const canon: Mode2Source = "iem.archive"; diff --git a/packages-ts/meta/tests/research.test.ts b/packages-ts/meta/tests/research.test.ts index 2466a097..aa2c887c 100644 --- a/packages-ts/meta/tests/research.test.ts +++ b/packages-ts/meta/tests/research.test.ts @@ -481,7 +481,11 @@ describe("research() — Mode 2 single-source pin (opts.source)", () => { const fetchSpy = installMixedSourceMock(); await expect( research("NYC", PIN_DAY, PIN_DAY, { source: "banana", now: PIN_NOW, cache: null }), - ).rejects.toThrow(/Mode 2 source must be one of/); + ).rejects.toThrow( + // Item 1 (v1.12.1): canonical cross-SDK text — `research(): source must + // be one of ...` (NOT the narrower researchBySource "Mode 2 source ..."). + 'research(): source must be one of ["awc","awc.live","ghcnh","ghcnh.archive","iem","iem.archive","iem.live"]; got "banana"', + ); expect(fetchSpy).not.toHaveBeenCalled(); }); @@ -547,4 +551,25 @@ describe("research() — Mode 2 single-source pin (opts.source)", () => { expect(row?.obs_low_f).toBe(68); expect(row?.obs_mean_f).toBe(80.5); }); + + it("sources: null (JSON round-trip of Python None) behaves as ABSENT — full merge, no throw", async () => { + // Item 4 (v1.12.1): the Phase 21 wire contract (research.types.ts + // `present()` / Python `sources is not None`) treats a JSON-round-tripped + // Python `sources=None` (arriving as `null`) as not-provided. The + // deferred-`sources` guard previously used `!== undefined`, which threw + // the v0.3 NotImplemented-style error for `sources: null`; `!= null` lets + // the full merge proceed. Mirrors the `source: null` guard above. + installMixedSourceMock(); + const rows = await research("NYC", PIN_DAY, PIN_DAY, { + sources: null as unknown as string[], + now: PIN_NOW, + cache: null, + }); + expect(rows).toHaveLength(1); + const row = rows[0]; + expect(row?.obs_count).toBe(4); + expect(row?.obs_high_f).toBe(104); + expect(row?.obs_low_f).toBe(68); + expect(row?.obs_mean_f).toBe(80.5); + }); }); diff --git a/packages-ts/weather/package.json b/packages-ts/weather/package.json index eae22685..f21108da 100644 --- a/packages-ts/weather/package.json +++ b/packages-ts/weather/package.json @@ -1,6 +1,6 @@ { "name": "@mostlyrightmd/weather", - "version": "1.12.0", + "version": "1.12.1", "description": "Weather data for TypeScript / Node — live METAR (AWC), ASOS archive (IEM), historical observations (GHCNh), and NWS climate text products (CLI). For quants, ML training pipelines, and weather-bot agents. Direct public-API access, no hosted backend.", "keywords": [ "weather", diff --git a/packages/core/pyproject.toml b/packages/core/pyproject.toml index 3096be2f..ce207f87 100644 --- a/packages/core/pyproject.toml +++ b/packages/core/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mostlyrightmd" -version = "1.12.0" +version = "1.12.1" description = "Python SDK for quants, ML engineers, and AI agents — one interface to public data. Adapters ship weather + prediction-market settlements (Kalshi NHIGH/NLOW, Polymarket) today; SEC filings, Federal Reserve series, court filings, FDA approvals, and equities are next. Schema-versioned, leakage-free, local-first. Imports as `mostlyright`." readme = "README.md" license = "MIT" diff --git a/packages/core/src/mostlyright/__init__.py b/packages/core/src/mostlyright/__init__.py index 9e31ef23..569db045 100644 --- a/packages/core/src/mostlyright/__init__.py +++ b/packages/core/src/mostlyright/__init__.py @@ -20,7 +20,13 @@ # Split-distribution namespace: extend __path__ to discover sibling packages' contributions. __path__ = __import__("pkgutil").extend_path(__path__, __name__) -__version__ = "0.1.0rc1" +from importlib.metadata import PackageNotFoundError +from importlib.metadata import version as _dist_version + +try: + __version__ = _dist_version("mostlyrightmd") +except PackageNotFoundError: # editable/source tree without installed dist metadata + __version__ = "0.0.0+unknown" from mostlyright.discover import discover from mostlyright.research import research diff --git a/packages/core/src/mostlyright/research.py b/packages/core/src/mostlyright/research.py index b073b424..c66acd62 100644 --- a/packages/core/src/mostlyright/research.py +++ b/packages/core/src/mostlyright/research.py @@ -46,6 +46,7 @@ from __future__ import annotations +import contextlib import json import logging from datetime import UTC, datetime, timedelta @@ -1718,10 +1719,6 @@ def research( ] raise ValueError(f"research(): selectors are mutually exclusive; got {provided!r}") - # `sources=` (plural) vs `source=` (singular) are mutually exclusive. - if sources is not None and source is not None: - raise ValueError("research(): sources= and source= are mutually exclusive") - # `sources=` (plural, multi-source subset) is validated at the # mutual-exclusion boundary but its data-selection wiring lands in # v0.3. The singular `source=` pin IS wired below (a post-fetch @@ -1960,8 +1957,6 @@ def research( # Phase 3.4: surface qc summary on df.attrs when the qc=True opt-in # ran. Mode 1 parity rows themselves are unchanged (only attrs). if qc_summary is not None and as_dataframe: - import contextlib - with contextlib.suppress(AttributeError): result.attrs["qc"] = qc_summary @@ -1970,8 +1965,6 @@ def research( # df.attrs["source"]). No attrs change when source is None — the # parity firewall keeps the default path untouched. if source is not None and as_dataframe: - import contextlib - with contextlib.suppress(AttributeError): result.attrs["source"] = source @@ -1990,11 +1983,21 @@ def research( # stamp this (pairs are heterogeneous), so the fallback to now() # is acceptable here — but future pairs-level provenance work # should populate result.attrs["retrieved_at"] for consistency. + # Mode 2 provenance parity: when source= is pinned, thread it explicitly + # into the wrapper the same way mode2.research_by_source does + # (source=source), so the wrapper contract doesn't depend on the + # df.attrs round-trip above. On this path the round-trip also works + # (as_dataframe=True guarantees a real DataFrame), so this is + # defense-in-depth, not a behavior fix. When source is None the default + # path is byte-identical (unchanged attrs-derived string). + wrap_source = ( + source if source is not None else str(result.attrs.get("source", "mostlyright.research")) + ) return wrap_result( result, backend=backend, # type: ignore[arg-type] return_type=return_type, # type: ignore[arg-type] - source=str(result.attrs.get("source", "mostlyright.research")), + source=wrap_source, retrieved_at=result.attrs.get("retrieved_at"), schema_id=None, # research() returns heterogeneous pairs, not a single schema qc=qc_summary, diff --git a/packages/core/tests/test_namespace.py b/packages/core/tests/test_namespace.py index 8f80dee4..6c4a78d5 100644 --- a/packages/core/tests/test_namespace.py +++ b/packages/core/tests/test_namespace.py @@ -10,11 +10,15 @@ ``import mostlyright.weather`` for users who installed both distributions. """ +from importlib.metadata import version as _dist_version + import mostlyright def test_core_importable(): - assert mostlyright.__version__ == "0.1.0rc1" + # __version__ is derived from the installed dist metadata (mostlyrightmd), + # not a hardcoded literal — keeps it in lockstep with pyproject on bumps. + assert mostlyright.__version__ == _dist_version("mostlyrightmd") def test_path_extended_for_split_distribution(): @@ -29,11 +33,11 @@ def test_weather_subpackage_importable(): """``import mostlyright.weather`` must succeed when mostlyrightmd-weather is installed.""" import mostlyright.weather - assert mostlyright.weather.__version__ == "0.1.0rc1" + assert mostlyright.weather.__version__ == _dist_version("mostlyrightmd-weather") def test_markets_subpackage_importable(): """``import mostlyright.markets`` must succeed when mostlyrightmd-markets is installed.""" import mostlyright.markets - assert mostlyright.markets.__version__ == "0.0.1" + assert mostlyright.markets.__version__ == _dist_version("mostlyrightmd-markets") diff --git a/packages/core/tests/test_research_source_pin.py b/packages/core/tests/test_research_source_pin.py index 232db6fa..07b1d7b3 100644 --- a/packages/core/tests/test_research_source_pin.py +++ b/packages/core/tests/test_research_source_pin.py @@ -188,3 +188,191 @@ def test_source_pin_noop_when_all_rows_already_match(tmp_path, monkeypatch) -> N df_pinned[["obs_high_f", "obs_low_f", "obs_count"]], df_unpinned[["obs_high_f", "obs_low_f", "obs_count"]], ) + + +# Four-source window shared by several coverage tests below. Jan 8 (EST) is +# the settlement bucket; iem carries the 45/40 spread, awc the 99 high and +# ghcnh the 20 low — both of which the pin must exclude. +_FOUR_SOURCE_OBS = [ + { + "station_code": "KNYC", + "observed_at": "2025-01-08T15:00:00Z", + "source": "iem", + "temp_f": 40.0, + }, + { + "station_code": "KNYC", + "observed_at": "2025-01-08T18:00:00Z", + "source": "iem", + "temp_f": 45.0, + }, + { + "station_code": "KNYC", + "observed_at": "2025-01-08T16:00:00Z", + "source": "awc", + "temp_f": 99.0, + }, + { + "station_code": "KNYC", + "observed_at": "2025-01-08T17:00:00Z", + "source": "ghcnh", + "temp_f": 20.0, + }, +] + + +def test_unknown_source_message_is_canonical(tmp_path, monkeypatch) -> None: + """P3-1: the unknown-source ValueError text is the pinned cross-SDK contract. + + Both SDKs raise exactly ``research(): source must be one of ; got ``. The prefix ``research(): source must be one of`` + is the shared locked substring; each SDK renders the vocabulary list + + the offending value in its own native repr/quote style (Python here). + """ + research_module = _load() + monkeypatch.setenv("MOSTLYRIGHT_CACHE_DIR", str(tmp_path)) + monkeypatch.setattr( + research_module, + "_fetch_observations_range", + lambda *a, **kw: (_ for _ in ()).throw(AssertionError("fetch must not run")), + ) + monkeypatch.setattr(research_module, "_all_caches_warm", lambda *a, **kw: True) + + from mostlyright.mode2 import _VALID_OBSERVATION_SOURCES + + with pytest.raises(ValueError) as exc: + research_module.research("KNYC", "2025-01-06", "2025-01-12", source="bogus.source") + + msg = str(exc.value) + expected = ( + "research(): source must be one of " + f"{sorted(_VALID_OBSERVATION_SOURCES)}; got {'bogus.source'!r}" + ) + assert msg == expected + # Pin the shared cross-SDK prefix independently so a reword of the + # locked substring fails loudly even if the suffix drifts. + assert msg.startswith("research(): source must be one of") + + +def test_source_pin_as_dataframe_false_filters_rows(tmp_path, monkeypatch) -> None: + """P3-6a: as_dataframe=False + source pin returns list[dict] pair rows whose + aggregates were fed ONLY by the pinned-source observations.""" + research_module = _load() + _patch_fetchers(research_module, monkeypatch, tmp_path, _FOUR_SOURCE_OBS) + + rows = research_module.research( + "KNYC", "2025-01-06", "2025-01-12", source="iem", as_dataframe=False + ) + assert isinstance(rows, list) + assert all(isinstance(r, dict) for r in rows) + + jan8 = next(r for r in rows if r["date"] == "2025-01-08") + # iem-only spread — the awc 99 high and ghcnh 20 low are excluded. + assert jan8["obs_high_f"] == 45.0 + assert jan8["obs_low_f"] == 40.0 + assert jan8["obs_count"] == 2 + # as_dataframe=False returns raw pair dicts — no df.attrs surface here. + + +def test_source_pin_polars_wrapper_filters_aggregates(tmp_path, monkeypatch) -> None: + """P3-6b: backend="polars" + source pin. polars requires + return_type="wrapper" (polars frames have no df.attrs); the pin + provenance rides on MostlyRightResult.source and the aggregates + reflect only the pinned-source rows. + + Skip-guarded: the CI fast suite runs WITHOUT the [polars] extra. + """ + pytest.importorskip("polars") + from mostlyright.core.result import MostlyRightResult + + research_module = _load() + _patch_fetchers(research_module, monkeypatch, tmp_path, _FOUR_SOURCE_OBS) + + out = research_module.research( + "KNYC", + "2025-01-06", + "2025-01-12", + source="iem", + backend="polars", + return_type="wrapper", + ) + assert isinstance(out, MostlyRightResult) + import polars as pl + + assert isinstance(out.frame, pl.DataFrame) + assert out.source == "iem" + + pdf = out.frame_as_pandas() + jan8 = pdf.loc[pdf["date"] == pd.Timestamp("2025-01-08")].iloc[0] + assert jan8["obs_high_f"] == 45.0 + assert jan8["obs_low_f"] == 40.0 + assert jan8["obs_count"] == 2 + + +def test_source_pin_pandas_wrapper_carries_pin(tmp_path, monkeypatch) -> None: + """P3-6 / wrapper-path provenance: backend="pandas", return_type="wrapper" + exercises the wrap_result path WITHOUT needing the polars extra. The + pinned source is threaded explicitly (mode2 parity), so the wrapper + carries source="iem" rather than degrading to the generic default.""" + from mostlyright.core.result import MostlyRightResult + + research_module = _load() + _patch_fetchers(research_module, monkeypatch, tmp_path, _FOUR_SOURCE_OBS) + + out = research_module.research( + "KNYC", + "2025-01-06", + "2025-01-12", + source="iem", + backend="pandas", + return_type="wrapper", + ) + assert isinstance(out, MostlyRightResult) + assert out.source == "iem" + + pdf = out.frame_as_pandas() + jan8 = pdf.loc[pd.Timestamp("2025-01-08")] + assert jan8["obs_high_f"] == 45.0 + assert jan8["obs_low_f"] == 40.0 + assert jan8["obs_count"] == 2 + + +def test_source_pin_unpinned_wrapper_source_default_unchanged(tmp_path, monkeypatch) -> None: + """Wrapper-path default byte-identity: with NO source pin the wrapper + still reports the generic "mostlyright.research" provenance — the + explicit-pin threading must not perturb the unpinned default.""" + from mostlyright.core.result import MostlyRightResult + + research_module = _load() + _patch_fetchers(research_module, monkeypatch, tmp_path, _FOUR_SOURCE_OBS) + + out = research_module.research( + "KNYC", + "2025-01-06", + "2025-01-12", + backend="pandas", + return_type="wrapper", + ) + assert isinstance(out, MostlyRightResult) + assert out.source == "mostlyright.research" + + +def test_source_pin_empty_result_carries_attrs_and_columns(tmp_path, monkeypatch) -> None: + """P3-6c: pinning a source that NO fetched row matches yields an empty + aggregate (obs_count 0, null highs/lows) but still returns the standard + frame — attrs["source"] stamped, standard columns present, no crash.""" + research_module = _load() + # Only awc/ghcnh rows in the window; pinning "iem" filters them all out. + obs = [r for r in _FOUR_SOURCE_OBS if r["source"] != "iem"] + _patch_fetchers(research_module, monkeypatch, tmp_path, obs) + + df = research_module.research("KNYC", "2025-01-06", "2025-01-12", source="iem") + + assert df.attrs["source"] == "iem" + for col in ("station", "cli_high_f", "cli_low_f", "obs_high_f", "obs_low_f", "obs_count"): + assert col in df.columns, f"missing standard column {col!r}" + + row = df.loc[pd.Timestamp("2025-01-08")] + assert row["obs_count"] == 0 + assert pd.isna(row["obs_high_f"]) + assert pd.isna(row["obs_low_f"]) diff --git a/packages/markets/pyproject.toml b/packages/markets/pyproject.toml index b56ee0db..e2eb4b50 100644 --- a/packages/markets/pyproject.toml +++ b/packages/markets/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mostlyrightmd-markets" -version = "1.12.0" +version = "1.12.1" description = "Prediction-market data for Python — Kalshi NHIGH/NLOW weather-contract resolvers, Polymarket discovery + settlement, and Kalshi + Polymarket trade history. For quants, backtesting, and ML training pipelines. Imports as `mostlyright.markets`." readme = "README.md" license = "MIT" diff --git a/packages/markets/src/mostlyright/markets/__init__.py b/packages/markets/src/mostlyright/markets/__init__.py index 803f109d..b09f99fc 100644 --- a/packages/markets/src/mostlyright/markets/__init__.py +++ b/packages/markets/src/mostlyright/markets/__init__.py @@ -15,5 +15,12 @@ ``monorepo-v0.14.1/src/mostlyright/_polymarket.py``) """ -__version__ = "0.0.1" +from importlib.metadata import PackageNotFoundError +from importlib.metadata import version as _dist_version + +try: + __version__ = _dist_version("mostlyrightmd-markets") +except PackageNotFoundError: # editable/source tree without installed dist metadata + __version__ = "0.0.0+unknown" + __all__ = ["__version__"] diff --git a/packages/weather/pyproject.toml b/packages/weather/pyproject.toml index 2189c1a8..1d6a8a6b 100644 --- a/packages/weather/pyproject.toml +++ b/packages/weather/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mostlyrightmd-weather" -version = "1.12.0" +version = "1.12.1" description = "Weather data for Python — live METAR (AWC), ASOS archive (IEM), historical observations (GHCNh), and NWS climate text products (CLI). For quants, ML training pipelines, and weather-bot agents. Direct public-API access, no hosted backend. Imports as `mostlyright.weather`." readme = "README.md" license = "MIT" diff --git a/packages/weather/src/mostlyright/weather/__init__.py b/packages/weather/src/mostlyright/weather/__init__.py index 6b896c90..b92dd4d9 100644 --- a/packages/weather/src/mostlyright/weather/__init__.py +++ b/packages/weather/src/mostlyright/weather/__init__.py @@ -30,6 +30,8 @@ keeps working without preprocessing in v0.1.0. """ +from importlib.metadata import PackageNotFoundError +from importlib.metadata import version as _dist_version from typing import TYPE_CHECKING, Any from mostlyright.weather._fetchers._open_meteo import fetch_open_meteo @@ -39,7 +41,10 @@ if TYPE_CHECKING: from mostlyright.weather import cwop as cwop -__version__ = "0.1.0rc1" +try: + __version__ = _dist_version("mostlyrightmd-weather") +except PackageNotFoundError: # editable/source tree without installed dist metadata + __version__ = "0.0.0+unknown" __all__ = ["__version__", "cwop", "fetch_open_meteo", "obs", "satellite"] diff --git a/uv.lock b/uv.lock index 44414400..8d58d0ca 100644 --- a/uv.lock +++ b/uv.lock @@ -1628,7 +1628,7 @@ wheels = [ [[package]] name = "mostlyrightmd" -version = "1.12.0" +version = "1.12.1" source = { editable = "packages/core" } dependencies = [ { name = "httpx" }, @@ -1672,7 +1672,7 @@ provides-extras = ["parquet", "research", "polars"] [[package]] name = "mostlyrightmd-markets" -version = "1.12.0" +version = "1.12.1" source = { editable = "packages/markets" } dependencies = [ { name = "httpx" }, @@ -1726,7 +1726,7 @@ provides-extras = ["parquet", "polymarket", "trades", "earnings", "polars"] [[package]] name = "mostlyrightmd-weather" -version = "1.12.0" +version = "1.12.1" source = { editable = "packages/weather" } dependencies = [ { name = "filelock" },