nhl-bigquery 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. nhl_bigquery-0.1.0/.github/workflows/release.yml +41 -0
  2. nhl_bigquery-0.1.0/.github/workflows/test.yml +30 -0
  3. nhl_bigquery-0.1.0/.gitignore +16 -0
  4. nhl_bigquery-0.1.0/.python-version +1 -0
  5. nhl_bigquery-0.1.0/CHANGELOG.md +12 -0
  6. nhl_bigquery-0.1.0/CONTRIBUTING.md +12 -0
  7. nhl_bigquery-0.1.0/LICENSE +21 -0
  8. nhl_bigquery-0.1.0/PKG-INFO +92 -0
  9. nhl_bigquery-0.1.0/README.md +58 -0
  10. nhl_bigquery-0.1.0/nhl_bigquery/__init__.py +5 -0
  11. nhl_bigquery-0.1.0/nhl_bigquery/_version.py +1 -0
  12. nhl_bigquery-0.1.0/nhl_bigquery/boxscore/__init__.py +1 -0
  13. nhl_bigquery-0.1.0/nhl_bigquery/boxscore/schema.py +146 -0
  14. nhl_bigquery-0.1.0/nhl_bigquery/boxscore/transform.py +93 -0
  15. nhl_bigquery-0.1.0/nhl_bigquery/cli.py +443 -0
  16. nhl_bigquery-0.1.0/nhl_bigquery/client.py +85 -0
  17. nhl_bigquery-0.1.0/nhl_bigquery/docs/__init__.py +1 -0
  18. nhl_bigquery-0.1.0/nhl_bigquery/docs/renderers.py +169 -0
  19. nhl_bigquery-0.1.0/nhl_bigquery/docs/taxonomy.py +46 -0
  20. nhl_bigquery-0.1.0/nhl_bigquery/games/__init__.py +1 -0
  21. nhl_bigquery-0.1.0/nhl_bigquery/games/schema.py +126 -0
  22. nhl_bigquery-0.1.0/nhl_bigquery/games/transform.py +70 -0
  23. nhl_bigquery-0.1.0/nhl_bigquery/officials/__init__.py +1 -0
  24. nhl_bigquery-0.1.0/nhl_bigquery/officials/schema.py +48 -0
  25. nhl_bigquery-0.1.0/nhl_bigquery/officials/transform.py +37 -0
  26. nhl_bigquery-0.1.0/nhl_bigquery/plays/__init__.py +1 -0
  27. nhl_bigquery-0.1.0/nhl_bigquery/plays/merge.py +101 -0
  28. nhl_bigquery-0.1.0/nhl_bigquery/plays/schema.py +1014 -0
  29. nhl_bigquery-0.1.0/nhl_bigquery/plays/strength.py +49 -0
  30. nhl_bigquery-0.1.0/nhl_bigquery/plays/transform.py +231 -0
  31. nhl_bigquery-0.1.0/nhl_bigquery/runs.py +174 -0
  32. nhl_bigquery-0.1.0/nhl_bigquery/schema.py +65 -0
  33. nhl_bigquery-0.1.0/nhl_bigquery/shifts/__init__.py +1 -0
  34. nhl_bigquery-0.1.0/nhl_bigquery/shifts/schema.py +66 -0
  35. nhl_bigquery-0.1.0/nhl_bigquery/shifts/transform.py +44 -0
  36. nhl_bigquery-0.1.0/nhl_bigquery/standings/__init__.py +1 -0
  37. nhl_bigquery-0.1.0/nhl_bigquery/standings/schema.py +89 -0
  38. nhl_bigquery-0.1.0/nhl_bigquery/standings/transform.py +39 -0
  39. nhl_bigquery-0.1.0/nhl_bigquery/time_utils.py +45 -0
  40. nhl_bigquery-0.1.0/nhl_bigquery/verify/__init__.py +1 -0
  41. nhl_bigquery-0.1.0/nhl_bigquery/verify/base.py +31 -0
  42. nhl_bigquery-0.1.0/nhl_bigquery/verify/internal.py +150 -0
  43. nhl_bigquery-0.1.0/nhl_bigquery/verify/nhl_api.py +244 -0
  44. nhl_bigquery-0.1.0/nhl_bigquery/writer.py +162 -0
  45. nhl_bigquery-0.1.0/pyproject.toml +68 -0
  46. nhl_bigquery-0.1.0/scripts/capture_fixture.py +68 -0
  47. nhl_bigquery-0.1.0/tests/conftest.py +18 -0
  48. nhl_bigquery-0.1.0/tests/fixtures/.gitkeep +0 -0
  49. nhl_bigquery-0.1.0/tests/fixtures/games/2024020001/boxscore.json +1019 -0
  50. nhl_bigquery-0.1.0/tests/fixtures/games/2024020001/landing.json +678 -0
  51. nhl_bigquery-0.1.0/tests/fixtures/games/2024020001/play-by-play.json +8681 -0
  52. nhl_bigquery-0.1.0/tests/fixtures/games/2024020001/right-rail.json +563 -0
  53. nhl_bigquery-0.1.0/tests/fixtures/games/2024020001/shift-charts.json +17246 -0
  54. nhl_bigquery-0.1.0/tests/fixtures/games/2024020009/boxscore.json +998 -0
  55. nhl_bigquery-0.1.0/tests/fixtures/games/2024020009/landing.json +1239 -0
  56. nhl_bigquery-0.1.0/tests/fixtures/games/2024020009/play-by-play.json +8102 -0
  57. nhl_bigquery-0.1.0/tests/fixtures/games/2024020009/right-rail.json +363 -0
  58. nhl_bigquery-0.1.0/tests/fixtures/games/2024020009/shift-charts.json +18212 -0
  59. nhl_bigquery-0.1.0/tests/fixtures/games/2024020022/boxscore.json +1001 -0
  60. nhl_bigquery-0.1.0/tests/fixtures/games/2024020022/landing.json +940 -0
  61. nhl_bigquery-0.1.0/tests/fixtures/games/2024020022/play-by-play.json +8900 -0
  62. nhl_bigquery-0.1.0/tests/fixtures/games/2024020022/right-rail.json +336 -0
  63. nhl_bigquery-0.1.0/tests/fixtures/games/2024020022/shift-charts.json +17729 -0
  64. nhl_bigquery-0.1.0/tests/fixtures/score/2024-10-08.json +1346 -0
  65. nhl_bigquery-0.1.0/tests/fixtures/standings/2024-10-08.json +2730 -0
  66. nhl_bigquery-0.1.0/tests/integration/.gitkeep +0 -0
  67. nhl_bigquery-0.1.0/tests/integration/__init__.py +1 -0
  68. nhl_bigquery-0.1.0/tests/integration/test_sync_one_game.py +41 -0
  69. nhl_bigquery-0.1.0/tests/unit/.gitkeep +0 -0
  70. nhl_bigquery-0.1.0/tests/unit/test_boxscore_schema.py +31 -0
  71. nhl_bigquery-0.1.0/tests/unit/test_cli_docs.py +22 -0
  72. nhl_bigquery-0.1.0/tests/unit/test_cli_sync.py +58 -0
  73. nhl_bigquery-0.1.0/tests/unit/test_cli_verify.py +14 -0
  74. nhl_bigquery-0.1.0/tests/unit/test_games_schema.py +30 -0
  75. nhl_bigquery-0.1.0/tests/unit/test_nhl_client.py +74 -0
  76. nhl_bigquery-0.1.0/tests/unit/test_officials_schema.py +24 -0
  77. nhl_bigquery-0.1.0/tests/unit/test_other_transforms.py +64 -0
  78. nhl_bigquery-0.1.0/tests/unit/test_plays_merge.py +82 -0
  79. nhl_bigquery-0.1.0/tests/unit/test_plays_schema.py +147 -0
  80. nhl_bigquery-0.1.0/tests/unit/test_plays_transform.py +80 -0
  81. nhl_bigquery-0.1.0/tests/unit/test_renderers.py +38 -0
  82. nhl_bigquery-0.1.0/tests/unit/test_runs.py +37 -0
  83. nhl_bigquery-0.1.0/tests/unit/test_schema_invariants.py +53 -0
  84. nhl_bigquery-0.1.0/tests/unit/test_shifts_schema.py +17 -0
  85. nhl_bigquery-0.1.0/tests/unit/test_standings_schema.py +17 -0
  86. nhl_bigquery-0.1.0/tests/unit/test_strength_state.py +56 -0
  87. nhl_bigquery-0.1.0/tests/unit/test_time_utils.py +47 -0
  88. nhl_bigquery-0.1.0/tests/unit/test_verify_internal.py +49 -0
  89. nhl_bigquery-0.1.0/tests/unit/test_verify_nhl_api.py +55 -0
  90. nhl_bigquery-0.1.0/tests/unit/test_writer.py +52 -0
  91. nhl_bigquery-0.1.0/uv.lock +1152 -0
@@ -0,0 +1,41 @@
1
+ name: release
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ jobs:
9
+ build:
10
+ runs-on: ubuntu-latest
11
+ outputs:
12
+ dist-path: ${{ steps.build.outputs.dist-path }}
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+ - uses: astral-sh/setup-uv@v3
16
+ - run: uv python install 3.13
17
+ - name: Create venv
18
+ run: uv venv
19
+ - name: Install build tools
20
+ run: uv pip install build
21
+ - id: build
22
+ run: |
23
+ uv run python -m build
24
+ echo "dist-path=dist" >> $GITHUB_OUTPUT
25
+ - uses: actions/upload-artifact@v4
26
+ with:
27
+ name: dist
28
+ path: dist/
29
+
30
+ publish:
31
+ needs: build
32
+ runs-on: ubuntu-latest
33
+ environment: pypi
34
+ permissions:
35
+ id-token: write
36
+ steps:
37
+ - uses: actions/download-artifact@v4
38
+ with:
39
+ name: dist
40
+ path: dist/
41
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,30 @@
1
+ name: test
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ strategy:
12
+ matrix:
13
+ python-version: ["3.11", "3.12", "3.13"]
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+ - uses: astral-sh/setup-uv@v3
17
+ - name: Set up Python ${{ matrix.python-version }}
18
+ run: uv python install ${{ matrix.python-version }}
19
+ - name: Create venv
20
+ run: uv venv
21
+ - name: Install
22
+ run: uv pip install -e ".[dev]"
23
+ - name: Lint
24
+ run: uv run ruff check .
25
+ - name: Type check
26
+ run: uv run pyright
27
+ - name: Test
28
+ run: uv run pytest -v --cov=nhl_bigquery --cov-report=xml
29
+ - name: Build
30
+ run: uv run python -m build
@@ -0,0 +1,16 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *$py.class
4
+ *.so
5
+ .Python
6
+ build/
7
+ dist/
8
+ *.egg-info/
9
+ .pytest_cache/
10
+ .ruff_cache/
11
+ .coverage
12
+ htmlcov/
13
+ .venv/
14
+ venv/
15
+ .env
16
+ *.duckdb
@@ -0,0 +1 @@
1
+ 3.13
@@ -0,0 +1,12 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ Initial release.
6
+
7
+ - `sync` command writes six tables in lockstep chunks: nhl_plays (with on-ice arrays merged from shift-charts), games, game_officials, boxscore_stats, shifts, standings
8
+ - `--resume` support via `_nhl_ingest_runs` log
9
+ - 5 doc renderers (bq-apply / llm / dictionary / markdown / dbt) backed by ColumnSpec SSoT
10
+ - `verify --source internal` runs 8 zero-tolerance consistency checks
11
+ - `verify --source nhl-api` reconstructs and compares team-season / player-season / game-boxscore against authoritative endpoints
12
+ - Backfill from 2010-11 onward (coordinate era)
@@ -0,0 +1,12 @@
1
+ # Contributing
2
+
3
+ Bug reports and PRs welcome at https://github.com/blahovec-labs/nhl-bigquery.
4
+
5
+ ## Dev setup
6
+
7
+ pip install -e ".[dev]"
8
+ pytest -q
9
+
10
+ ## Capturing test fixtures
11
+
12
+ python scripts/capture_fixture.py --game-id 2024020001 --out tests/fixtures/games/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jason Blahovec
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,92 @@
1
+ Metadata-Version: 2.4
2
+ Name: nhl-bigquery
3
+ Version: 0.1.0
4
+ Summary: NHL play-by-play → BigQuery: idempotent ingestion + LLM-friendly docs + verification
5
+ Project-URL: Homepage, https://github.com/blahovec-labs/nhl-bigquery
6
+ Project-URL: Issues, https://github.com/blahovec-labs/nhl-bigquery/issues
7
+ Project-URL: Changelog, https://github.com/blahovec-labs/nhl-bigquery/blob/main/CHANGELOG.md
8
+ Author: Jason Blahovec
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: bigquery,data-engineering,hockey,nhl,play-by-play
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Database
19
+ Classifier: Topic :: Scientific/Engineering
20
+ Requires-Python: >=3.11
21
+ Requires-Dist: db-dtypes<2.0,>=1.0
22
+ Requires-Dist: google-cloud-bigquery<4.0,>=3.20
23
+ Requires-Dist: pandas<3.0,>=2.0
24
+ Requires-Dist: pyarrow<19.0,>=15.0
25
+ Requires-Dist: requests<3.0,>=2.31
26
+ Provides-Extra: dev
27
+ Requires-Dist: build>=1.2.0; extra == 'dev'
28
+ Requires-Dist: pyright>=1.1.380; extra == 'dev'
29
+ Requires-Dist: pytest-cov>=5.0; extra == 'dev'
30
+ Requires-Dist: pytest>=8.0; extra == 'dev'
31
+ Requires-Dist: responses>=0.25; extra == 'dev'
32
+ Requires-Dist: ruff>=0.6.0; extra == 'dev'
33
+ Description-Content-Type: text/markdown
34
+
35
+ # nhl-bigquery
36
+
37
+ Idempotent NHL play-by-play (with on-ice arrays merged from shift-charts) → BigQuery ingestion, with first-class documentation for SQL/LLM agents and verification against the NHL public API.
38
+
39
+ ## Install
40
+
41
+ pip install nhl-bigquery
42
+
43
+ ## Quickstart
44
+
45
+ gcloud auth application-default login
46
+ nhl-bigquery sync \
47
+ --start 2024-10-01 --end 2025-06-30 \
48
+ --plays-table myproject.mydataset.nhl_plays
49
+
50
+ This writes six tables to `myproject.mydataset.*`:
51
+
52
+ - `nhl_plays` — one row per event, with `home_on_ice_ids` / `away_on_ice_ids` arrays
53
+ - `games` — schedule dimension
54
+ - `game_officials` — referees + linesmen per game
55
+ - `boxscore_stats` — per-player per-game stats
56
+ - `shifts` — per-shift per-player intervals
57
+ - `standings` — daily team-standings snapshots
58
+
59
+ ## Backfill
60
+
61
+ Backfill 15 seasons in resumable monthly chunks:
62
+
63
+ nhl-bigquery sync \
64
+ --start 2010-10-01 --end 2026-05-11 \
65
+ --chunk-by month --resume \
66
+ --plays-table myproject.mydataset.nhl_plays
67
+
68
+ `--resume` skips chunks already recorded as `success` or `empty` in
69
+ `<dataset>._nhl_ingest_runs`. Re-running with the same `--chunk-by` is
70
+ safe; switching between runs will re-process (chunks must match exactly).
71
+
72
+ ## Documentation
73
+
74
+ nhl-bigquery docs --format llm > NHL_FOR_LLMS.md
75
+ nhl-bigquery docs --format bq-apply --table myproject.mydataset.nhl_plays
76
+
77
+ Five formats: `bq-apply` (push descriptions to BigQuery), `llm` (one
78
+ Markdown file packing every column for LLM context), `dictionary`
79
+ (JSON rows for a data dictionary table), `markdown` (human reference),
80
+ and `dbt` (dbt YAML schema stub).
81
+
82
+ ## Verification
83
+
84
+ nhl-bigquery verify --source internal \
85
+ --aggregation internal-consistency \
86
+ --table myproject.mydataset.nhl_plays
87
+
88
+ nhl-bigquery verify --source nhl-api \
89
+ --aggregation team-season --metric all --season 2024 \
90
+ --table myproject.mydataset.nhl_plays
91
+
92
+ MIT licensed. This software does not include or distribute NHL data.
@@ -0,0 +1,58 @@
1
+ # nhl-bigquery
2
+
3
+ Idempotent NHL play-by-play (with on-ice arrays merged from shift-charts) → BigQuery ingestion, with first-class documentation for SQL/LLM agents and verification against the NHL public API.
4
+
5
+ ## Install
6
+
7
+ pip install nhl-bigquery
8
+
9
+ ## Quickstart
10
+
11
+ gcloud auth application-default login
12
+ nhl-bigquery sync \
13
+ --start 2024-10-01 --end 2025-06-30 \
14
+ --plays-table myproject.mydataset.nhl_plays
15
+
16
+ This writes six tables to `myproject.mydataset.*`:
17
+
18
+ - `nhl_plays` — one row per event, with `home_on_ice_ids` / `away_on_ice_ids` arrays
19
+ - `games` — schedule dimension
20
+ - `game_officials` — referees + linesmen per game
21
+ - `boxscore_stats` — per-player per-game stats
22
+ - `shifts` — per-shift per-player intervals
23
+ - `standings` — daily team-standings snapshots
24
+
25
+ ## Backfill
26
+
27
+ Backfill 15 seasons in resumable monthly chunks:
28
+
29
+ nhl-bigquery sync \
30
+ --start 2010-10-01 --end 2026-05-11 \
31
+ --chunk-by month --resume \
32
+ --plays-table myproject.mydataset.nhl_plays
33
+
34
+ `--resume` skips chunks already recorded as `success` or `empty` in
35
+ `<dataset>._nhl_ingest_runs`. Re-running with the same `--chunk-by` is
36
+ safe; switching between runs will re-process (chunks must match exactly).
37
+
38
+ ## Documentation
39
+
40
+ nhl-bigquery docs --format llm > NHL_FOR_LLMS.md
41
+ nhl-bigquery docs --format bq-apply --table myproject.mydataset.nhl_plays
42
+
43
+ Five formats: `bq-apply` (push descriptions to BigQuery), `llm` (one
44
+ Markdown file packing every column for LLM context), `dictionary`
45
+ (JSON rows for a data dictionary table), `markdown` (human reference),
46
+ and `dbt` (dbt YAML schema stub).
47
+
48
+ ## Verification
49
+
50
+ nhl-bigquery verify --source internal \
51
+ --aggregation internal-consistency \
52
+ --table myproject.mydataset.nhl_plays
53
+
54
+ nhl-bigquery verify --source nhl-api \
55
+ --aggregation team-season --metric all --season 2024 \
56
+ --table myproject.mydataset.nhl_plays
57
+
58
+ MIT licensed. This software does not include or distribute NHL data.
@@ -0,0 +1,5 @@
1
+ """nhl-bigquery: NHL play-by-play → BigQuery ingestion."""
2
+
3
+ from nhl_bigquery._version import __version__
4
+
5
+ __all__ = ["__version__"]
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1 @@
1
+ """boxscore_stats table: per-player per-game stats."""
@@ -0,0 +1,146 @@
1
+ """BOXSCORE_SCHEMA: per-player per-game stats (skaters + goalies)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from nhl_bigquery.games.schema import _col
6
+ from nhl_bigquery.schema import ColumnSpec, PartitioningSpec
7
+
8
+
9
+ def get_partitioning() -> PartitioningSpec:
10
+ return PartitioningSpec(
11
+ field="game_date", type="DAY", clustering=["player_id", "team_id"],
12
+ )
13
+
14
+
15
+ BOXSCORE_SCHEMA: list[ColumnSpec] = [
16
+ # Common
17
+ _col("game_id", "INT64", "REQUIRED", "NHL game ID.",
18
+ "FK to games.game_id.", tags=["identifier", "join_key"], example=2024020001,
19
+ api_eq="id", api_src="id"),
20
+ _col("game_date", "DATE", "REQUIRED", "Game date.",
21
+ "Partition key for this table.", tags=["temporal"], example="2024-10-08",
22
+ api_eq="gameDate", api_src="gameDate"),
23
+ _col("player_id", "INT64", "REQUIRED", "NHL Player ID.",
24
+ "Canonical player identifier; FK to player landing API.",
25
+ tags=["identifier", "join_key"], example=8478402,
26
+ api_eq="playerId", api_src="playerId"),
27
+ _col("team_id", "INT64", "REQUIRED", "Team ID this game.",
28
+ "NHL team_id of the team the player suited up for. "
29
+ "Mid-season trades produce different team_ids across games.",
30
+ tags=["identifier", "join_key", "team"], example=10,
31
+ api_eq="(side teamId)", api_src="(side teamId)"),
32
+ _col("player_position_category", "STRING", "REQUIRED",
33
+ "Discriminator: 'skater' or 'goalie'.",
34
+ "Splits skater rows (with G/A/+-/PIM/...) from goalie rows (SA/SV/SV%/GAA/...).",
35
+ tags=["identifier"], values=["skater", "goalie"], example="skater",
36
+ api_eq="(derived: 'forwards'/'defense'/'goalies' bucket)",
37
+ api_src="(derived)"),
38
+ _col("position_code", "STRING", "NULLABLE", "Position code (C/L/R/D/G).",
39
+ "Single-letter position: C=center, L=left wing, R=right wing, D=defense, G=goalie.",
40
+ tags=["identifier"], values=["C", "L", "R", "D", "G"], example="C",
41
+ api_eq="position", api_src="position"),
42
+ _col("sweater_number", "INT64", "NULLABLE", "Jersey number worn this game.",
43
+ "Sweater number; not stable across trades.",
44
+ tags=["identifier"], range_=(1.0, 99.0), example=97,
45
+ api_eq="sweaterNumber", api_src="sweaterNumber"),
46
+ _col("toi", "STRING", "NULLABLE", "Time on ice (MM:SS).",
47
+ "Total time on ice. String MM:SS to preserve fidelity; use toi_seconds for arithmetic.",
48
+ tags=["measure"], example="20:34",
49
+ api_eq="toi", api_src="toi"),
50
+ # Skater-only
51
+ _col("goals", "INT64", "NULLABLE", "Goals scored.",
52
+ "Goals scored by this skater in this game.", tags=["measure"], example=1,
53
+ api_eq="goals", api_src="goals"),
54
+ _col("assists", "INT64", "NULLABLE", "Assists.",
55
+ "Assists in this game.", tags=["measure"], example=2,
56
+ api_eq="assists", api_src="assists"),
57
+ _col("points", "INT64", "NULLABLE", "Points (G + A).",
58
+ "Goals + assists. Stored directly to avoid query-time arithmetic.",
59
+ tags=["measure"], example=3, api_eq="points", api_src="points"),
60
+ _col("plus_minus", "INT64", "NULLABLE", "Plus/minus.",
61
+ "Plus/minus for this skater in this game.", tags=["measure"], example=2,
62
+ api_eq="plusMinus", api_src="plusMinus"),
63
+ _col("pim", "INT64", "NULLABLE", "Penalty minutes.",
64
+ "Penalty minutes assessed to this player.", tags=["measure"], example=0,
65
+ api_eq="pim", api_src="pim"),
66
+ _col("hits", "INT64", "NULLABLE", "Hits delivered.", "Body checks delivered.",
67
+ tags=["measure"], example=3, api_eq="hits", api_src="hits"),
68
+ _col("blocked_shots", "INT64", "NULLABLE", "Shots blocked.",
69
+ "Shots the player blocked.", tags=["measure"], example=1,
70
+ api_eq="blockedShots", api_src="blockedShots"),
71
+ _col("shots", "INT64", "NULLABLE", "Shots on goal.",
72
+ "Shots on goal (SOG); does not include missed or blocked shots.",
73
+ tags=["measure"], example=4, api_eq="shots", api_src="shots"),
74
+ _col("faceoffs", "INT64", "NULLABLE", "Faceoffs taken.",
75
+ "Total faceoffs taken (won + lost).", tags=["measure"], example=10,
76
+ api_eq="faceoffs", api_src="faceoffs"),
77
+ _col("faceoff_winning_pctg", "FLOAT64", "NULLABLE", "Faceoff win %.",
78
+ "Faceoff winning percentage as a decimal (0.0-1.0).",
79
+ tags=["measure"], range_=(0.0, 1.0), example=0.55,
80
+ api_eq="faceoffWinningPctg", api_src="faceoffWinningPctg"),
81
+ _col("power_play_goals", "INT64", "NULLABLE", "Power-play goals.",
82
+ "Goals scored while on the power play.", tags=["measure"], example=1,
83
+ api_eq="powerPlayGoals", api_src="powerPlayGoals"),
84
+ _col("power_play_points", "INT64", "NULLABLE", "Power-play points.",
85
+ "Goals + assists earned while on the power play.",
86
+ tags=["measure"], example=2, api_eq="powerPlayPoints", api_src="powerPlayPoints"),
87
+ _col("shorthanded_goals", "INT64", "NULLABLE", "Shorthanded goals.",
88
+ "Goals scored while shorthanded.", tags=["measure"], example=0,
89
+ api_eq="shorthandedGoals", api_src="shorthandedGoals"),
90
+ _col("sh_points", "INT64", "NULLABLE", "Shorthanded points.",
91
+ "Goals + assists earned while shorthanded.", tags=["measure"], example=0,
92
+ api_eq="shPoints", api_src="shPoints"),
93
+ _col("power_play_toi", "STRING", "NULLABLE", "Power-play TOI.",
94
+ "Time on ice while on the power play (MM:SS).",
95
+ tags=["measure"], example="2:14",
96
+ api_eq="powerPlayToi", api_src="powerPlayToi"),
97
+ _col("shorthanded_toi", "STRING", "NULLABLE", "Shorthanded TOI.",
98
+ "Time on ice while shorthanded (MM:SS).",
99
+ tags=["measure"], example="1:32",
100
+ api_eq="shorthandedToi", api_src="shorthandedToi"),
101
+ # Goalie-only
102
+ _col("shots_against", "INT64", "NULLABLE", "Total shots against.",
103
+ "Shots faced by this goalie in this game.", tags=["measure"], example=32,
104
+ api_eq="saveShotsAgainst (sum)", api_src="saveShotsAgainst"),
105
+ _col("saves", "INT64", "NULLABLE", "Total saves.",
106
+ "Saves made. Equals shots_against - goals_against.",
107
+ tags=["measure"], example=30,
108
+ api_eq="(saveShotsAgainst - goalsAgainst)", api_src="(derived)"),
109
+ _col("save_pctg", "FLOAT64", "NULLABLE", "Save percentage.",
110
+ "Save percentage as decimal (0.0-1.0).",
111
+ tags=["measure"], range_=(0.0, 1.0), example=0.938,
112
+ api_eq="savePctg", api_src="savePctg"),
113
+ _col("goals_against", "INT64", "NULLABLE", "Goals against.",
114
+ "Goals scored against this goalie in this game.",
115
+ tags=["measure"], example=2,
116
+ api_eq="goalsAgainst", api_src="goalsAgainst"),
117
+ _col("even_strength_shots_against", "INT64", "NULLABLE",
118
+ "Even-strength shots against.", "Shots faced at even strength.",
119
+ tags=["measure"], example=20,
120
+ api_eq="evenStrengthShotsAgainst", api_src="evenStrengthShotsAgainst"),
121
+ _col("power_play_shots_against", "INT64", "NULLABLE",
122
+ "PP shots against.", "Shots faced while shorthanded.",
123
+ tags=["measure"], example=8,
124
+ api_eq="powerPlayShotsAgainst", api_src="powerPlayShotsAgainst"),
125
+ _col("shorthanded_shots_against", "INT64", "NULLABLE",
126
+ "SH shots against.", "Shots faced while on the power play.",
127
+ tags=["measure"], example=4,
128
+ api_eq="shorthandedShotsAgainst", api_src="shorthandedShotsAgainst"),
129
+ _col("even_strength_goals_against", "INT64", "NULLABLE",
130
+ "EV goals against.", "Goals allowed at even strength.",
131
+ tags=["measure"], example=1,
132
+ api_eq="evenStrengthGoalsAgainst", api_src="evenStrengthGoalsAgainst"),
133
+ _col("power_play_goals_against", "INT64", "NULLABLE",
134
+ "PP goals against.", "Goals allowed while shorthanded.",
135
+ tags=["measure"], example=1,
136
+ api_eq="powerPlayGoalsAgainst", api_src="powerPlayGoalsAgainst"),
137
+ _col("shorthanded_goals_against", "INT64", "NULLABLE",
138
+ "SH goals against.", "Goals allowed while on the power play.",
139
+ tags=["measure"], example=0,
140
+ api_eq="shorthandedGoalsAgainst", api_src="shorthandedGoalsAgainst"),
141
+ _col("ingested_at", "TIMESTAMP", "REQUIRED",
142
+ "Ingestion timestamp.",
143
+ "UTC timestamp when this row was written.",
144
+ tags=["meta"], example="2026-05-22T17:00:00Z",
145
+ api_eq=None, api_src="(set by ingestion)"),
146
+ ]
@@ -0,0 +1,93 @@
1
+ """Boxscore transform: /gamecenter/{id}/boxscore → per-player rows (skater + goalie)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import UTC, datetime
6
+ from typing import Any
7
+
8
+ import pandas as pd
9
+
10
+ _SKATER_FIELDS = (
11
+ "goals", "assists", "points", "plusMinus", "pim", "hits", "blockedShots",
12
+ "shots", "faceoffs", "faceoffWinningPctg",
13
+ "powerPlayGoals", "powerPlayPoints", "shorthandedGoals", "shPoints",
14
+ "powerPlayToi", "shorthandedToi",
15
+ )
16
+
17
+ _GOALIE_FIELDS = (
18
+ "saveShotsAgainst", "goalsAgainst", "savePctg",
19
+ "evenStrengthShotsAgainst", "powerPlayShotsAgainst", "shorthandedShotsAgainst",
20
+ "evenStrengthGoalsAgainst", "powerPlayGoalsAgainst", "shorthandedGoalsAgainst",
21
+ )
22
+
23
+
24
+ def _camel_to_snake(s: str) -> str:
25
+ out = []
26
+ for c in s:
27
+ if c.isupper():
28
+ out.append("_" + c.lower())
29
+ else:
30
+ out.append(c)
31
+ return "".join(out).lstrip("_")
32
+
33
+
34
+ def transform_boxscore_to_df(bs: dict[str, Any]) -> pd.DataFrame:
35
+ """Extract per-player per-game rows from a boxscore response."""
36
+ game_id = int(bs["id"])
37
+ game_date = bs.get("gameDate")
38
+ pgs = bs.get("playerByGameStats") or {}
39
+ rows: list[dict[str, Any]] = []
40
+ ingested_at = datetime.now(UTC)
41
+
42
+ for side in ("awayTeam", "homeTeam"):
43
+ team_id = int((bs.get(side) or {}).get("id"))
44
+ side_block = pgs.get(side) or {}
45
+ for cat, players in (
46
+ ("skater", side_block.get("forwards") or []),
47
+ ("skater", side_block.get("defense") or []),
48
+ ("goalie", side_block.get("goalies") or []),
49
+ ):
50
+ for p in players:
51
+ row: dict[str, Any] = {
52
+ "game_id": game_id,
53
+ "game_date": game_date,
54
+ "player_id": int(p["playerId"]),
55
+ "team_id": team_id,
56
+ "player_position_category": cat,
57
+ "position_code": p.get("position"),
58
+ "sweater_number": p.get("sweaterNumber"),
59
+ "toi": p.get("toi"),
60
+ "ingested_at": ingested_at,
61
+ }
62
+ # Skater fields
63
+ for f in _SKATER_FIELDS:
64
+ row[_camel_to_snake(f)] = p.get(f)
65
+ # Goalie fields (with renames)
66
+ # saveShotsAgainst may be "saves/shots" string (e.g. "19/22") or numeric
67
+ save_shots_raw = p.get("saveShotsAgainst")
68
+ goals_against = p.get("goalsAgainst")
69
+ shots_against_int = p.get("shotsAgainst")
70
+ saves_int = p.get("saves")
71
+ # Parse "saves/shots" string format if needed
72
+ if isinstance(save_shots_raw, str) and "/" in save_shots_raw:
73
+ parts = save_shots_raw.split("/")
74
+ try:
75
+ saves_int = saves_int if saves_int is not None else int(parts[0])
76
+ shots_against_int = (
77
+ shots_against_int if shots_against_int is not None else int(parts[1])
78
+ )
79
+ except (ValueError, IndexError):
80
+ pass
81
+ elif isinstance(save_shots_raw, (int, float)):
82
+ shots_against_int = (
83
+ shots_against_int if shots_against_int is not None else int(save_shots_raw)
84
+ )
85
+ row["shots_against"] = shots_against_int
86
+ row["saves"] = saves_int
87
+ row["save_pctg"] = p.get("savePctg")
88
+ row["goals_against"] = goals_against
89
+ for f in _GOALIE_FIELDS[3:]: # skip the three above
90
+ row[_camel_to_snake(f)] = p.get(f)
91
+ rows.append(row)
92
+
93
+ return pd.DataFrame(rows)