Erinaldorodrigues commited on
Commit
ea721f9
·
1 Parent(s): 626efb4

Handle unavailable seasons with validated fallback

Browse files
CHECKSUMS.sha256 CHANGED
@@ -10,9 +10,9 @@ a68770f781b7acacfaaf56e5110d9846b7001e87d9936d433990b8393d5b86e5 TUTORIAL.md
10
  e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 app/__init__.py
11
  c4891fc509f38fb94d9006be910c18b7573645c11da3532c38356302d99c7e55 app/config.py
12
  e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 app/core/__init__.py
13
- ac21a5ed1bb8695b6dad694de61c2c85b5ad7c086f5ca9b9c41f484084a88caf app/core/analyzer.py
14
  48c155a14807a76713edb7579076fa877587e5cff9e35f8f7d0fef35d821396f app/core/calibration.py
15
- 545b836f97ca6f9d847055ae2632f3dfefdee0749555e0e4622d7addc6f3b554 app/core/competitions.py
16
  0545a029d7488a10cc6d3dd8d2c69c38d5b6b3246ceb34b035ed0f2b77570e49 app/core/history.py
17
  d2a9298d6d322fecb72184990c6c7bd720be5b3b12fb9245615f3687ee890aba app/core/market.py
18
  70922a2b322aeb7512ba9147e391687cb8d50083523d03fa3fd6ae9441a94fc2 app/core/names.py
@@ -21,9 +21,9 @@ abf7d9982399348a65b3f1e1b14ccdbe03e0b37c984eff790ed59248738fa56c app/core/ticke
21
  8c76f9b59d601719dee412f494a964ea588ff5e6393af49c203765524fe26bd2 app/logging_config.py
22
  2c30ffa77b5c69a67ab90a257504af4372909de8e95c4f61e32b752352f112d9 app/main.py
23
  34193815ff5356263a850c254ab48a2850b12f3b623831ea81b61708fced9519 app/models.py
24
- c1af8e753cd3c63f2286719b2ae091ec3364d365f287bf7db9cb3747f2203ca0 app/pipeline.py
25
  e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 app/providers/__init__.py
26
- bd060539c6d9bedb43d57aa7ff1e514bcf0a837b25d3db37212052fd23068465 app/providers/football_data.py
27
  1ffb376b0856a863a512d06ae36a736003692a8bbf5e74cfe23704c2c98f29d9 app/providers/http_client.py
28
  255c1050280cb7c1816c4c0c05aba96c4ac75c547415ada1e91dd8d17078fea2 app/providers/odds_api.py
29
  adfcb95146b325af7cfddb67e1ab29f714f912736374c6d0d384f9ba93139659 app/static/app.js
@@ -36,5 +36,5 @@ e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 data/.gitkeep
36
  8636f9ab1a075be9f3039e2a6471837259c4f36b625bcaf7a3d9a1edd2419c6d requirements-dev.txt
37
  27d9fc069a1ce60bdf78cf32226af023d57a63c5ab4076797edb3d05258a5397 requirements.txt
38
  66b37f1efcf7bc1f3b2c2437dc991f489a0413b3de9268565fd17eca882880b2 tests/test_core.py
39
- a0af163abacd7022e70f87b92e7da89a1b87aea4c34588287bd2e4c345d8228a tests/test_pipeline.py
40
- c1dadd7cd92ba0732867b4f7ba7775c09427358a5a1bc5e43a062956c257d964 tests/test_robustness.py
 
10
  e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 app/__init__.py
11
  c4891fc509f38fb94d9006be910c18b7573645c11da3532c38356302d99c7e55 app/config.py
12
  e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 app/core/__init__.py
13
+ 9511f0e32638b1951f5e153d76e827bf67c54b250e6863c32fcadd76f01ab43a app/core/analyzer.py
14
  48c155a14807a76713edb7579076fa877587e5cff9e35f8f7d0fef35d821396f app/core/calibration.py
15
+ ecc608094af12bc17a728263a7e963bde91ea26ac2a8715101e7493735f95453 app/core/competitions.py
16
  0545a029d7488a10cc6d3dd8d2c69c38d5b6b3246ceb34b035ed0f2b77570e49 app/core/history.py
17
  d2a9298d6d322fecb72184990c6c7bd720be5b3b12fb9245615f3687ee890aba app/core/market.py
18
  70922a2b322aeb7512ba9147e391687cb8d50083523d03fa3fd6ae9441a94fc2 app/core/names.py
 
21
  8c76f9b59d601719dee412f494a964ea588ff5e6393af49c203765524fe26bd2 app/logging_config.py
22
  2c30ffa77b5c69a67ab90a257504af4372909de8e95c4f61e32b752352f112d9 app/main.py
23
  34193815ff5356263a850c254ab48a2850b12f3b623831ea81b61708fced9519 app/models.py
24
+ 87235eccba7b95cc05a9a5239d6c17ceeb8ddc11c1c2efebd6daea2182fbb92f app/pipeline.py
25
  e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 app/providers/__init__.py
26
+ d6f32051d88974c1816db0b83570698cdc35c83d9381ce99d769160905e656f1 app/providers/football_data.py
27
  1ffb376b0856a863a512d06ae36a736003692a8bbf5e74cfe23704c2c98f29d9 app/providers/http_client.py
28
  255c1050280cb7c1816c4c0c05aba96c4ac75c547415ada1e91dd8d17078fea2 app/providers/odds_api.py
29
  adfcb95146b325af7cfddb67e1ab29f714f912736374c6d0d384f9ba93139659 app/static/app.js
 
36
  8636f9ab1a075be9f3039e2a6471837259c4f36b625bcaf7a3d9a1edd2419c6d requirements-dev.txt
37
  27d9fc069a1ce60bdf78cf32226af023d57a63c5ab4076797edb3d05258a5397 requirements.txt
38
  66b37f1efcf7bc1f3b2c2437dc991f489a0413b3de9268565fd17eca882880b2 tests/test_core.py
39
+ 75901c556c3e578d6e075d81a9fb686e6129731c997bbe6555611b85bcc67d56 tests/test_pipeline.py
40
+ 2698e47665232af00cc3d951b3c673473eb7a889c364a4da57f01fc809801526 tests/test_robustness.py
app/core/analyzer.py CHANGED
@@ -5,7 +5,7 @@ from statistics import pstdev
5
 
6
  from app.config import MODEL_VERSION
7
  from app.core.calibration import calibrate_probability
8
- from app.core.competitions import competition_for_sport_key
9
  from app.core.market import market_consensus
10
  from app.core.names import build_team_catalog, resolve_event_pair
11
  from app.core.stats import build_elo, predictive_models, tune_ensemble_weights
@@ -121,7 +121,7 @@ def analyze_events(
121
 
122
  comp_code = spec.football_data_code
123
  comp_matches = matches_by_comp.get(comp_code, [])
124
- if len(comp_matches) < 40:
125
  rejected.append({
126
  "event": event_name,
127
  "reason": f"histórico insuficiente em {comp_code} ({len(comp_matches)} jogos)",
 
5
 
6
  from app.config import MODEL_VERSION
7
  from app.core.calibration import calibrate_probability
8
+ from app.core.competitions import MIN_COMPETITION_HISTORY, competition_for_sport_key
9
  from app.core.market import market_consensus
10
  from app.core.names import build_team_catalog, resolve_event_pair
11
  from app.core.stats import build_elo, predictive_models, tune_ensemble_weights
 
121
 
122
  comp_code = spec.football_data_code
123
  comp_matches = matches_by_comp.get(comp_code, [])
124
+ if len(comp_matches) < MIN_COMPETITION_HISTORY:
125
  rejected.append({
126
  "event": event_name,
127
  "reason": f"histórico insuficiente em {comp_code} ({len(comp_matches)} jogos)",
app/core/competitions.py CHANGED
@@ -4,6 +4,9 @@ from dataclasses import dataclass
4
  from datetime import date
5
 
6
 
 
 
 
7
  @dataclass(frozen=True)
8
  class CompetitionSpec:
9
  sport_key: str
 
4
  from datetime import date
5
 
6
 
7
+ MIN_COMPETITION_HISTORY = 40
8
+
9
+
10
  @dataclass(frozen=True)
11
  class CompetitionSpec:
12
  sport_key: str
app/pipeline.py CHANGED
@@ -165,6 +165,11 @@ class DailyPipeline:
165
  f"{len(football_meta['errors'])} consulta(s) de histórico falharam; "
166
  "o cache foi preservado quando disponível."
167
  )
 
 
 
 
 
168
 
169
  remaining = odds.quota.get("remaining")
170
  if isinstance(remaining, int) and remaining < 50:
 
165
  f"{len(football_meta['errors'])} consulta(s) de histórico falharam; "
166
  "o cache foi preservado quando disponível."
167
  )
168
+ if football_meta.get("fallbacks"):
169
+ warnings.append(
170
+ f"{len(football_meta['fallbacks'])} temporada(s) atual(is) ainda "
171
+ "não publicada(s); o histórico anterior foi carregado."
172
+ )
173
 
174
  remaining = odds.quota.get("remaining")
175
  if isinstance(remaining, int) and remaining < 50:
app/providers/football_data.py CHANGED
@@ -6,7 +6,11 @@ from datetime import datetime, timedelta, timezone
6
  import logging
7
  import time
8
 
9
- from app.core.competitions import requested_competitions, season_start_year
 
 
 
 
10
  from app.models import FinishedMatch
11
  from app.providers.http_client import ResilientHTTP, ProviderError
12
 
@@ -110,7 +114,11 @@ class FootballDataProvider:
110
  away_aliases=cls._team_aliases(away),
111
  )
112
 
113
- async def _fetch_season(self, competition_code: str, season: int) -> tuple[list[FinishedMatch], str | None]:
 
 
 
 
114
  try:
115
  payload = await self._get(
116
  f"{self.BASE}/competitions/{competition_code}/matches",
@@ -123,15 +131,15 @@ class FootballDataProvider:
123
  and "token" in message
124
  and any(word in message for word in ("invalid", "missing", "invalido"))
125
  )
126
- if exc.status_code == 401 or invalid_token:
127
  raise
128
- return [], f"{competition_code}/{season}: {exc}"
129
  except Exception as exc:
130
- return [], f"{competition_code}/{season}: {type(exc).__name__}: {exc}"
131
 
132
  items = payload.get("matches")
133
  if not isinstance(items, list):
134
- return [], f"{competition_code}/{season}: resposta sem lista de partidas"
135
 
136
  out: list[FinishedMatch] = []
137
  for item in items:
@@ -139,8 +147,8 @@ class FootballDataProvider:
139
  if match:
140
  out.append(match)
141
  if items and not out:
142
- return [], f"{competition_code}/{season}: nenhuma partida válida na resposta"
143
- return out, None
144
 
145
  @staticmethod
146
  def _merge(matches: list[FinishedMatch]) -> list[FinishedMatch]:
@@ -168,18 +176,24 @@ class FootballDataProvider:
168
  m for m in cached_matches
169
  if m.utc_date >= cutoff and any(m.competition == s.football_data_code for s in specs)
170
  ]
171
- meta = {"competitions": {}, "errors": [], "cache_loaded": len(all_matches)}
172
- successful_current_requests = 0
 
 
 
 
 
173
 
174
  for spec in specs:
175
  current_season = season_start_year(spec, now.date())
176
  comp_cache = [m for m in all_matches if m.competition == spec.football_data_code]
177
 
178
- current, error = await self._fetch_season(spec.football_data_code, current_season)
179
- if error:
180
- meta["errors"].append(error)
181
- else:
182
- successful_current_requests += 1
 
183
  all_matches.extend(current)
184
 
185
  recent_comp = [
@@ -188,19 +202,50 @@ class FootballDataProvider:
188
  ]
189
 
190
  previous_loaded = False
 
191
  # Early in a season the current-season sample is too shallow.
192
  # Fetch the previous season only when the cache/current data does not
193
  # already provide a reasonable modelling base.
194
  if len(recent_comp) < 140:
195
- previous, prev_error = await self._fetch_season(
196
  spec.football_data_code,
197
  current_season - 1,
198
  )
199
  if prev_error:
200
  meta["errors"].append(prev_error)
201
  else:
202
- previous_loaded = True
203
  all_matches.extend(previous)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
204
 
205
  comp_final = [
206
  m for m in self._merge(all_matches)
@@ -213,9 +258,9 @@ class FootballDataProvider:
213
  "previous_loaded": previous_loaded,
214
  }
215
 
216
- if specs and successful_current_requests == 0 and meta["errors"]:
217
  raise ProviderError(
218
- "Todas as consultas atuais ao football-data.org falharam; "
219
  "o estado anterior foi preservado"
220
  )
221
 
 
6
  import logging
7
  import time
8
 
9
+ from app.core.competitions import (
10
+ MIN_COMPETITION_HISTORY,
11
+ requested_competitions,
12
+ season_start_year,
13
+ )
14
  from app.models import FinishedMatch
15
  from app.providers.http_client import ResilientHTTP, ProviderError
16
 
 
114
  away_aliases=cls._team_aliases(away),
115
  )
116
 
117
+ async def _fetch_season(
118
+ self,
119
+ competition_code: str,
120
+ season: int,
121
+ ) -> tuple[list[FinishedMatch], str | None, int | None]:
122
  try:
123
  payload = await self._get(
124
  f"{self.BASE}/competitions/{competition_code}/matches",
 
131
  and "token" in message
132
  and any(word in message for word in ("invalid", "missing", "invalido"))
133
  )
134
+ if exc.status_code in {401, 429} or invalid_token:
135
  raise
136
+ return [], f"{competition_code}/{season}: {exc}", exc.status_code
137
  except Exception as exc:
138
+ return [], f"{competition_code}/{season}: {type(exc).__name__}: {exc}", None
139
 
140
  items = payload.get("matches")
141
  if not isinstance(items, list):
142
+ return [], f"{competition_code}/{season}: resposta sem lista de partidas", 200
143
 
144
  out: list[FinishedMatch] = []
145
  for item in items:
 
147
  if match:
148
  out.append(match)
149
  if items and not out:
150
+ return [], f"{competition_code}/{season}: nenhuma partida válida na resposta", 200
151
+ return out, None, None
152
 
153
  @staticmethod
154
  def _merge(matches: list[FinishedMatch]) -> list[FinishedMatch]:
 
176
  m for m in cached_matches
177
  if m.utc_date >= cutoff and any(m.competition == s.football_data_code for s in specs)
178
  ]
179
+ meta = {
180
+ "competitions": {},
181
+ "errors": [],
182
+ "fallbacks": [],
183
+ "cache_loaded": len(all_matches),
184
+ }
185
+ successful_sources = 0
186
 
187
  for spec in specs:
188
  current_season = season_start_year(spec, now.date())
189
  comp_cache = [m for m in all_matches if m.competition == spec.football_data_code]
190
 
191
+ current, error, error_status = await self._fetch_season(
192
+ spec.football_data_code,
193
+ current_season,
194
+ )
195
+ if not error:
196
+ successful_sources += 1
197
  all_matches.extend(current)
198
 
199
  recent_comp = [
 
202
  ]
203
 
204
  previous_loaded = False
205
+ previous_usable: list[FinishedMatch] = []
206
  # Early in a season the current-season sample is too shallow.
207
  # Fetch the previous season only when the cache/current data does not
208
  # already provide a reasonable modelling base.
209
  if len(recent_comp) < 140:
210
+ previous, prev_error, _ = await self._fetch_season(
211
  spec.football_data_code,
212
  current_season - 1,
213
  )
214
  if prev_error:
215
  meta["errors"].append(prev_error)
216
  else:
 
217
  all_matches.extend(previous)
218
+ previous_usable = [
219
+ match for match in self._merge(previous)
220
+ if (
221
+ match.competition == spec.football_data_code
222
+ and cutoff <= match.utc_date < now
223
+ )
224
+ ]
225
+ previous_loaded = bool(previous_usable)
226
+
227
+ # football-data.org may not publish the newly-started season for
228
+ # every competition at the same time. A 404 is an expected
229
+ # availability gap only when the previous season alone supplies
230
+ # the minimum sample consumed by the analyzer.
231
+ if error:
232
+ fallback_accepted = (
233
+ error_status == 404
234
+ and len(previous_usable) >= MIN_COMPETITION_HISTORY
235
+ )
236
+ if fallback_accepted:
237
+ successful_sources += 1
238
+ meta["fallbacks"].append(
239
+ {
240
+ "competition": spec.football_data_code,
241
+ "requested_season": current_season,
242
+ "fallback_season": current_season - 1,
243
+ "matches": len(previous_usable),
244
+ "reason": "temporada atual ainda indisponível (HTTP 404)",
245
+ }
246
+ )
247
+ else:
248
+ meta["errors"].append(error)
249
 
250
  comp_final = [
251
  m for m in self._merge(all_matches)
 
258
  "previous_loaded": previous_loaded,
259
  }
260
 
261
+ if specs and successful_sources == 0 and meta["errors"]:
262
  raise ProviderError(
263
+ "Todas as consultas utilizáveis ao football-data.org falharam; "
264
  "o estado anterior foi preservado"
265
  )
266
 
tests/test_pipeline.py CHANGED
@@ -79,6 +79,12 @@ def test_pipeline_happy_path_persists_complete_state_and_closes_http(monkeypatch
79
  assert cached == []
80
  return _matches(), {
81
  "errors": [],
 
 
 
 
 
 
82
  "competitions": {"PL": {"matches": 45}},
83
  }
84
 
@@ -116,6 +122,7 @@ def test_pipeline_happy_path_persists_complete_state_and_closes_http(monkeypatch
116
  assert state["providers"]["football_data"]["ok"] is True
117
  assert state["providers"]["odds_api"]["ok"] is True
118
  assert state["providers"]["odds_api"]["estimated_cost_per_league"] == 1
 
119
  assert store.load_state()["generated_at"] == state["generated_at"]
120
  assert len(store.load_matches()) == 45
121
  assert store.load_history() == []
 
79
  assert cached == []
80
  return _matches(), {
81
  "errors": [],
82
+ "fallbacks": [{
83
+ "competition": "PL",
84
+ "requested_season": 2026,
85
+ "fallback_season": 2025,
86
+ "matches": 45,
87
+ }],
88
  "competitions": {"PL": {"matches": 45}},
89
  }
90
 
 
122
  assert state["providers"]["football_data"]["ok"] is True
123
  assert state["providers"]["odds_api"]["ok"] is True
124
  assert state["providers"]["odds_api"]["estimated_cost_per_league"] == 1
125
+ assert any("histórico anterior" in warning for warning in state["warnings"])
126
  assert store.load_state()["generated_at"] == state["generated_at"]
127
  assert len(store.load_matches()) == 45
128
  assert store.load_history() == []
tests/test_robustness.py CHANGED
@@ -13,6 +13,7 @@ from app.core.history import performance_metrics, settle_history
13
  from app.core.market import market_consensus
14
  from app.models import FinishedMatch
15
  from app.pipeline import DailyPipeline
 
16
  from app.providers.http_client import ProviderError, ResilientHTTP
17
  from app.providers.odds_api import OddsAPIProvider, _odds_api_timestamp
18
  from app.storage import StateStore
@@ -140,6 +141,101 @@ def test_odds_timestamp_normalizes_timezone_and_rollover():
140
  assert _odds_api_timestamp(local + timedelta(hours=6)) == "2027-01-01T02:59:59Z"
141
 
142
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
143
  def test_pipeline_running_includes_direct_lock_holder():
144
  pipeline = DailyPipeline(settings=None, store=None)
145
 
 
13
  from app.core.market import market_consensus
14
  from app.models import FinishedMatch
15
  from app.pipeline import DailyPipeline
16
+ from app.providers.football_data import FootballDataProvider
17
  from app.providers.http_client import ProviderError, ResilientHTTP
18
  from app.providers.odds_api import OddsAPIProvider, _odds_api_timestamp
19
  from app.storage import StateStore
 
141
  assert _odds_api_timestamp(local + timedelta(hours=6)) == "2027-01-01T02:59:59Z"
142
 
143
 
144
+ def test_football_provider_treats_404_with_usable_previous_season_as_fallback(monkeypatch):
145
+ provider = FootballDataProvider("test-token", http=None)
146
+ calls = []
147
+ previous_matches = [
148
+ FinishedMatch(
149
+ match_id=f"previous-{index}",
150
+ competition="PL",
151
+ utc_date=datetime.now(timezone.utc) - timedelta(days=index + 1),
152
+ home="Alpha FC",
153
+ away="Beta FC",
154
+ home_goals=2,
155
+ away_goals=0,
156
+ )
157
+ for index in range(40)
158
+ ]
159
+
160
+ async def fake_fetch(competition_code, season):
161
+ calls.append((competition_code, season))
162
+ if len(calls) == 1:
163
+ return [], f"{competition_code}/{season}: HTTP 404", 404
164
+ return previous_matches, None, None
165
+
166
+ monkeypatch.setattr(provider, "_fetch_season", fake_fetch)
167
+ matches, meta = asyncio.run(
168
+ provider.fetch_finished(240, ("soccer_epl",), cached_matches=[])
169
+ )
170
+
171
+ assert matches == list(reversed(previous_matches))
172
+ assert meta["errors"] == []
173
+ assert len(meta["fallbacks"]) == 1
174
+ assert meta["fallbacks"][0]["requested_season"] == calls[0][1]
175
+ assert meta["fallbacks"][0]["fallback_season"] == calls[1][1]
176
+ assert meta["fallbacks"][0]["matches"] == 40
177
+ assert meta["competitions"]["PL"]["previous_loaded"] is True
178
+
179
+
180
+ @pytest.mark.parametrize(
181
+ ("current_status", "previous_count"),
182
+ [(404, 39), (500, 40)],
183
+ )
184
+ def test_football_provider_does_not_mask_failed_or_insufficient_fallback(
185
+ monkeypatch,
186
+ current_status,
187
+ previous_count,
188
+ ):
189
+ provider = FootballDataProvider("test-token", http=None)
190
+ calls = 0
191
+
192
+ async def fake_fetch(competition_code, season):
193
+ nonlocal calls
194
+ calls += 1
195
+ if calls == 1:
196
+ return [], f"{competition_code}/{season}: HTTP {current_status}", current_status
197
+ matches = [
198
+ FinishedMatch(
199
+ match_id=f"fallback-{index}",
200
+ competition="PL",
201
+ utc_date=datetime.now(timezone.utc) - timedelta(days=index + 1),
202
+ home="Alpha FC",
203
+ away="Beta FC",
204
+ home_goals=1,
205
+ away_goals=0,
206
+ )
207
+ for index in range(previous_count)
208
+ ]
209
+ return matches, None, None
210
+
211
+ monkeypatch.setattr(provider, "_fetch_season", fake_fetch)
212
+ with pytest.raises(ProviderError, match="Todas as consultas utilizáveis"):
213
+ asyncio.run(provider.fetch_finished(240, ("soccer_epl",), cached_matches=[]))
214
+
215
+
216
+ @pytest.mark.parametrize(
217
+ ("status_code", "message"),
218
+ [
219
+ (401, "unauthorized"),
220
+ (429, "rate limit exceeded"),
221
+ (400, "Your API token is invalid"),
222
+ ],
223
+ )
224
+ def test_football_provider_fails_fast_for_auth_or_rate_limit(
225
+ monkeypatch,
226
+ status_code,
227
+ message,
228
+ ):
229
+ provider = FootballDataProvider("test-token", http=None)
230
+
231
+ async def failing_get(_url, _params):
232
+ raise ProviderError(message, status_code)
233
+
234
+ monkeypatch.setattr(provider, "_get", failing_get)
235
+ with pytest.raises(ProviderError, match=message):
236
+ asyncio.run(provider._fetch_season("PL", 2026))
237
+
238
+
239
  def test_pipeline_running_includes_direct_lock_holder():
240
  pipeline = DailyPipeline(settings=None, store=None)
241