dploydb 0.1.0__py3-none-any.whl

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.
dploydb/health.py ADDED
@@ -0,0 +1,625 @@
1
+ """Bounded HTTP readiness and optional candidate smoke checks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import os
7
+ import threading
8
+ import time
9
+ from collections.abc import Callable, Mapping
10
+ from dataclasses import dataclass
11
+ from enum import StrEnum
12
+ from pathlib import Path
13
+ from typing import Final, Self
14
+
15
+ import httpx
16
+
17
+ from dploydb.config import ApplicationConfig
18
+ from dploydb.redaction import JsonValue, SecretRegistry
19
+ from dploydb.runners.base import CommandExecutor, validate_release_identifier
20
+ from dploydb.subprocesses import CommandOutcome, CommandResult, SubprocessRunner
21
+
22
+ CANDIDATE_URL_ENV: Final = "DPLOYDB_CANDIDATE_URL"
23
+ APPLICATION_URL_ENV: Final = "DPLOYDB_APPLICATION_URL"
24
+ DPLOYDB_VERSION_ENV: Final = "DPLOYDB_VERSION"
25
+ DEFAULT_REQUEST_TIMEOUT_SECONDS: Final = 2.0
26
+ DEFAULT_RETRY_INTERVAL_SECONDS: Final = 0.1
27
+ DEFAULT_MAX_RESPONSE_BYTES: Final = 64 * 1024
28
+ SMOKE_MAX_OUTPUT_BYTES: Final = 256 * 1024
29
+
30
+
31
+ class HealthAttemptOutcome(StrEnum):
32
+ """Terminal result of one bounded readiness request."""
33
+
34
+ HEALTHY = "healthy"
35
+ UNHEALTHY_HTTP = "unhealthy_http"
36
+ REDIRECT_REFUSED = "redirect_refused"
37
+ TRANSPORT_ERROR = "transport_error"
38
+ DEADLINE_EXPIRED = "deadline_expired"
39
+ CANCELLED = "cancelled"
40
+
41
+
42
+ @dataclass(frozen=True, slots=True)
43
+ class BoundedResponseEvidence:
44
+ """Redacted diagnostic HTTP body retained with a strict memory bound."""
45
+
46
+ text: str
47
+ total_bytes: int
48
+ retained_bytes: int
49
+ truncated: bool
50
+
51
+ def __post_init__(self) -> None:
52
+ if self.total_bytes < 0 or self.retained_bytes < 0:
53
+ raise ValueError("response byte counts must not be negative")
54
+ if self.retained_bytes > self.total_bytes:
55
+ raise ValueError("retained response bytes must not exceed total bytes")
56
+ if self.truncated != (self.retained_bytes < self.total_bytes):
57
+ raise ValueError("response truncation evidence is contradictory")
58
+
59
+ def as_evidence(self) -> dict[str, JsonValue]:
60
+ return {
61
+ "text": self.text,
62
+ "total_bytes": self.total_bytes,
63
+ "retained_bytes": self.retained_bytes,
64
+ "truncated": self.truncated,
65
+ }
66
+
67
+
68
+ @dataclass(frozen=True, slots=True)
69
+ class HealthAttemptEvidence:
70
+ """Safe evidence for the last readiness request made before a decision."""
71
+
72
+ attempt: int
73
+ outcome: HealthAttemptOutcome
74
+ status_code: int | None
75
+ body: BoundedResponseEvidence | None
76
+ reason: str
77
+ duration_seconds: float
78
+
79
+ def __post_init__(self) -> None:
80
+ if self.attempt <= 0:
81
+ raise ValueError("health attempt number must be positive")
82
+ if not math.isfinite(self.duration_seconds) or self.duration_seconds < 0:
83
+ raise ValueError("health attempt duration must be finite and non-negative")
84
+ if not self.reason:
85
+ raise ValueError("health attempt reason must not be empty")
86
+
87
+ def as_evidence(self) -> dict[str, JsonValue]:
88
+ return {
89
+ "attempt": self.attempt,
90
+ "outcome": self.outcome.value,
91
+ "status_code": self.status_code,
92
+ "body": None if self.body is None else self.body.as_evidence(),
93
+ "reason": self.reason,
94
+ "duration_seconds": self.duration_seconds,
95
+ }
96
+
97
+
98
+ @dataclass(frozen=True, slots=True)
99
+ class ReadinessEvidence:
100
+ """Terminal readiness decision under one fixed monotonic deadline."""
101
+
102
+ url: str
103
+ healthy: bool
104
+ attempt_count: int
105
+ last_attempt: HealthAttemptEvidence | None
106
+ duration_seconds: float
107
+ reason: str
108
+
109
+ def __post_init__(self) -> None:
110
+ if self.attempt_count < 0:
111
+ raise ValueError("readiness attempt count must not be negative")
112
+ if self.last_attempt is None and self.attempt_count != 0:
113
+ raise ValueError("readiness without a last attempt must have zero attempts")
114
+ if self.last_attempt is not None and self.last_attempt.attempt != self.attempt_count:
115
+ raise ValueError("last readiness attempt must match the attempt count")
116
+ if self.healthy and (
117
+ self.last_attempt is None
118
+ or self.last_attempt.outcome is not HealthAttemptOutcome.HEALTHY
119
+ ):
120
+ raise ValueError("healthy readiness requires a healthy final attempt")
121
+ if not math.isfinite(self.duration_seconds) or self.duration_seconds < 0:
122
+ raise ValueError("readiness duration must be finite and non-negative")
123
+ if not self.reason:
124
+ raise ValueError("readiness reason must not be empty")
125
+
126
+ def as_evidence(self) -> dict[str, JsonValue]:
127
+ return {
128
+ "url": self.url,
129
+ "healthy": self.healthy,
130
+ "attempt_count": self.attempt_count,
131
+ "last_attempt": (
132
+ None if self.last_attempt is None else self.last_attempt.as_evidence()
133
+ ),
134
+ "duration_seconds": self.duration_seconds,
135
+ "reason": self.reason,
136
+ }
137
+
138
+
139
+ @dataclass(frozen=True, slots=True)
140
+ class CandidateHealthResult:
141
+ """Passing HTTP readiness plus optional complete smoke-command evidence."""
142
+
143
+ readiness: ReadinessEvidence
144
+ smoke: CommandResult | None
145
+
146
+ def __post_init__(self) -> None:
147
+ if not self.readiness.healthy:
148
+ raise ValueError("candidate health result requires passing readiness")
149
+ if self.smoke is not None and (
150
+ self.smoke.outcome is not CommandOutcome.SUCCEEDED
151
+ or self.smoke.stdout.truncated
152
+ or self.smoke.stderr.truncated
153
+ ):
154
+ raise ValueError("candidate health result requires complete successful smoke evidence")
155
+
156
+ def as_evidence(self) -> dict[str, JsonValue]:
157
+ return {
158
+ "readiness": self.readiness.as_evidence(),
159
+ "smoke": None if self.smoke is None else self.smoke.as_evidence(),
160
+ }
161
+
162
+
163
+ class CandidateHealthError(RuntimeError):
164
+ """Base class for a rejected candidate health check."""
165
+
166
+
167
+ class ReadinessCheckError(CandidateHealthError):
168
+ """The candidate did not return a 2xx response before the fixed deadline."""
169
+
170
+ def __init__(self, evidence: ReadinessEvidence) -> None:
171
+ self.evidence = evidence
172
+ super().__init__(evidence.reason)
173
+
174
+
175
+ class SmokeCheckError(CandidateHealthError):
176
+ """Readiness passed but the optional bounded smoke command did not."""
177
+
178
+ def __init__(
179
+ self,
180
+ message: str,
181
+ *,
182
+ readiness: ReadinessEvidence,
183
+ command: CommandResult,
184
+ ) -> None:
185
+ self.readiness = readiness
186
+ self.command = command
187
+ super().__init__(message)
188
+
189
+ @property
190
+ def cleanup_proven(self) -> bool:
191
+ return self.command.outcome is not CommandOutcome.CLEANUP_FAILED
192
+
193
+ def as_evidence(self) -> dict[str, JsonValue]:
194
+ return {
195
+ "readiness": self.readiness.as_evidence(),
196
+ "smoke": self.command.as_evidence(),
197
+ }
198
+
199
+
200
+ class _DeadlineExpired(RuntimeError):
201
+ pass
202
+
203
+
204
+ class _RequestCancelled(RuntimeError):
205
+ pass
206
+
207
+
208
+ class _BoundedBodyCapture:
209
+ def __init__(self, limit: int) -> None:
210
+ self._head_limit = limit // 2
211
+ self._tail_limit = limit - self._head_limit
212
+ self._head = bytearray()
213
+ self._tail = bytearray()
214
+ self._total_bytes = 0
215
+
216
+ def append(self, data: bytes) -> None:
217
+ self._total_bytes += len(data)
218
+ head_missing = self._head_limit - len(self._head)
219
+ if head_missing > 0:
220
+ self._head.extend(data[:head_missing])
221
+ data = data[head_missing:]
222
+ if not data or self._tail_limit == 0:
223
+ return
224
+ if len(data) >= self._tail_limit:
225
+ self._tail[:] = data[-self._tail_limit :]
226
+ return
227
+ self._tail.extend(data)
228
+ overflow = len(self._tail) - self._tail_limit
229
+ if overflow > 0:
230
+ del self._tail[:overflow]
231
+
232
+ def snapshot(self, secrets: SecretRegistry) -> BoundedResponseEvidence:
233
+ retained = bytes(self._head + self._tail)
234
+ retained_bytes = len(retained)
235
+ return BoundedResponseEvidence(
236
+ text=secrets.redact_text(retained.decode("utf-8", errors="replace")),
237
+ total_bytes=self._total_bytes,
238
+ retained_bytes=retained_bytes,
239
+ truncated=retained_bytes < self._total_bytes,
240
+ )
241
+
242
+
243
+ class ApplicationHealthChecker:
244
+ """Check one configured application endpoint without trusting process state alone."""
245
+
246
+ def __init__(
247
+ self,
248
+ *,
249
+ application: ApplicationConfig,
250
+ health_url: str,
251
+ database_environment_name: str,
252
+ secrets: SecretRegistry,
253
+ working_directory: Path,
254
+ smoke_environment: Mapping[str, str] | None = None,
255
+ health_url_environment_name: str = APPLICATION_URL_ENV,
256
+ command_environment: Mapping[str, str] | None = None,
257
+ command_runner: CommandExecutor | None = None,
258
+ client: httpx.Client | None = None,
259
+ transport: httpx.BaseTransport | None = None,
260
+ request_timeout_seconds: float = DEFAULT_REQUEST_TIMEOUT_SECONDS,
261
+ retry_interval_seconds: float = DEFAULT_RETRY_INTERVAL_SECONDS,
262
+ max_response_bytes: int = DEFAULT_MAX_RESPONSE_BYTES,
263
+ clock: Callable[[], float] = time.monotonic,
264
+ sleeper: Callable[[float], None] = time.sleep,
265
+ ) -> None:
266
+ if client is not None and transport is not None:
267
+ raise ValueError("provide either an HTTPX client or transport, not both")
268
+ if not database_environment_name or "=" in database_environment_name:
269
+ raise ValueError("database_environment_name must be a valid environment name")
270
+ if not health_url or not health_url_environment_name or "=" in health_url_environment_name:
271
+ raise ValueError("health URL and its environment name must be valid")
272
+ if not working_directory.is_absolute():
273
+ raise ValueError("working_directory must be absolute")
274
+ self.application = application
275
+ self.health_url = health_url
276
+ self.health_url_environment_name = health_url_environment_name
277
+ self.database_environment_name = database_environment_name
278
+ self.secrets = secrets
279
+ self.working_directory = working_directory
280
+ self.command_environment = dict(
281
+ os.environ if command_environment is None else command_environment
282
+ )
283
+ self.smoke_environment = dict(smoke_environment or {})
284
+ self.command_runner = command_runner or SubprocessRunner(
285
+ secrets=secrets,
286
+ max_output_bytes=SMOKE_MAX_OUTPUT_BYTES,
287
+ )
288
+ self.request_timeout_seconds = _positive_seconds(
289
+ request_timeout_seconds, "request_timeout_seconds"
290
+ )
291
+ self.retry_interval_seconds = _positive_seconds(
292
+ retry_interval_seconds, "retry_interval_seconds"
293
+ )
294
+ self.max_response_bytes = _positive_integer(max_response_bytes, "max_response_bytes")
295
+ self._clock = clock
296
+ self._sleeper = sleeper
297
+ self._owns_client = client is None
298
+ self._client = client or httpx.Client(
299
+ transport=transport,
300
+ follow_redirects=False,
301
+ trust_env=False,
302
+ )
303
+
304
+ def __enter__(self) -> Self:
305
+ return self
306
+
307
+ def __exit__(self, *_args: object) -> None:
308
+ self.close()
309
+
310
+ def close(self) -> None:
311
+ """Close only the internally owned HTTPX client."""
312
+ if self._owns_client:
313
+ self._client.close()
314
+
315
+ def check_application(
316
+ self,
317
+ *,
318
+ version: str,
319
+ database_path: Path,
320
+ cancellation_event: threading.Event | None = None,
321
+ ) -> CandidateHealthResult:
322
+ """Require readiness before the optional smoke command can execute."""
323
+ release = validate_release_identifier(version)
324
+ selected_database = _absolute_existing_file(database_path)
325
+ readiness = self.wait_until_ready(cancellation_event=cancellation_event)
326
+ command = self.application.smoke_command
327
+ if command is None:
328
+ return CandidateHealthResult(readiness=readiness, smoke=None)
329
+
330
+ environment = dict(self.command_environment)
331
+ environment.update(self.smoke_environment)
332
+ environment[self.database_environment_name] = str(selected_database)
333
+ environment[DPLOYDB_VERSION_ENV] = release
334
+ environment[self.health_url_environment_name] = self.health_url
335
+ smoke = self.command_runner.run(
336
+ command,
337
+ timeout_seconds=self.application.startup_timeout_seconds,
338
+ environment=environment,
339
+ working_directory=self.working_directory,
340
+ cancellation_event=cancellation_event,
341
+ )
342
+ failure = _smoke_failure(smoke)
343
+ if failure is not None:
344
+ raise SmokeCheckError(failure, readiness=readiness, command=smoke)
345
+ return CandidateHealthResult(readiness=readiness, smoke=smoke)
346
+
347
+ def wait_until_ready(
348
+ self,
349
+ *,
350
+ cancellation_event: threading.Event | None = None,
351
+ ) -> ReadinessEvidence:
352
+ """Poll the configured URL under one monotonic overall deadline."""
353
+ started_at = self._clock()
354
+ deadline = started_at + float(self.application.startup_timeout_seconds)
355
+ safe_url = self.secrets.redact_text(self.health_url)
356
+ attempts = 0
357
+ last_attempt: HealthAttemptEvidence | None = None
358
+
359
+ while True:
360
+ if cancellation_event is not None and cancellation_event.is_set():
361
+ evidence = ReadinessEvidence(
362
+ url=safe_url,
363
+ healthy=False,
364
+ attempt_count=attempts,
365
+ last_attempt=last_attempt,
366
+ duration_seconds=self._duration(started_at),
367
+ reason="candidate readiness was cancelled before a passing response",
368
+ )
369
+ raise ReadinessCheckError(evidence)
370
+ remaining = deadline - self._clock()
371
+ if remaining <= 0:
372
+ raise ReadinessCheckError(
373
+ self._failed_readiness(
374
+ safe_url=safe_url,
375
+ attempts=attempts,
376
+ last_attempt=last_attempt,
377
+ started_at=started_at,
378
+ )
379
+ )
380
+
381
+ attempts += 1
382
+ last_attempt = self._request_once(
383
+ attempt=attempts,
384
+ deadline=deadline,
385
+ cancellation_event=cancellation_event,
386
+ )
387
+ if last_attempt.outcome is HealthAttemptOutcome.HEALTHY:
388
+ return ReadinessEvidence(
389
+ url=safe_url,
390
+ healthy=True,
391
+ attempt_count=attempts,
392
+ last_attempt=last_attempt,
393
+ duration_seconds=self._duration(started_at),
394
+ reason="candidate returned a real HTTP 2xx readiness response",
395
+ )
396
+ if last_attempt.outcome is HealthAttemptOutcome.CANCELLED:
397
+ raise ReadinessCheckError(
398
+ ReadinessEvidence(
399
+ url=safe_url,
400
+ healthy=False,
401
+ attempt_count=attempts,
402
+ last_attempt=last_attempt,
403
+ duration_seconds=self._duration(started_at),
404
+ reason="candidate readiness was cancelled before a passing response",
405
+ )
406
+ )
407
+
408
+ remaining = deadline - self._clock()
409
+ if remaining <= 0:
410
+ continue
411
+ self._sleeper(min(self.retry_interval_seconds, remaining))
412
+
413
+ def _request_once(
414
+ self,
415
+ *,
416
+ attempt: int,
417
+ deadline: float,
418
+ cancellation_event: threading.Event | None,
419
+ ) -> HealthAttemptEvidence:
420
+ started_at = self._clock()
421
+ status_code: int | None = None
422
+ body: BoundedResponseEvidence | None = None
423
+ try:
424
+ remaining = deadline - started_at
425
+ if remaining <= 0:
426
+ raise _DeadlineExpired
427
+ timeout = min(self.request_timeout_seconds, remaining)
428
+ capture = _BoundedBodyCapture(self.max_response_bytes)
429
+ with self._client.stream(
430
+ "GET",
431
+ self.health_url,
432
+ follow_redirects=False,
433
+ timeout=httpx.Timeout(timeout),
434
+ ) as response:
435
+ status_code = response.status_code
436
+ for chunk in response.iter_bytes(chunk_size=8192):
437
+ if cancellation_event is not None and cancellation_event.is_set():
438
+ raise _RequestCancelled
439
+ if self._clock() >= deadline:
440
+ raise _DeadlineExpired
441
+ capture.append(chunk)
442
+ body = capture.snapshot(self.secrets)
443
+ except _RequestCancelled:
444
+ return self._attempt(
445
+ attempt,
446
+ HealthAttemptOutcome.CANCELLED,
447
+ status_code,
448
+ body,
449
+ "readiness request was cancelled",
450
+ started_at,
451
+ )
452
+ except _DeadlineExpired:
453
+ return self._attempt(
454
+ attempt,
455
+ HealthAttemptOutcome.DEADLINE_EXPIRED,
456
+ status_code,
457
+ body,
458
+ "readiness request reached the overall startup deadline",
459
+ started_at,
460
+ )
461
+ except httpx.TransportError as error:
462
+ return self._attempt(
463
+ attempt,
464
+ HealthAttemptOutcome.TRANSPORT_ERROR,
465
+ None,
466
+ None,
467
+ "readiness request failed: " + self.secrets.redact_text(str(error)),
468
+ started_at,
469
+ )
470
+
471
+ assert status_code is not None
472
+ if 200 <= status_code < 300:
473
+ outcome = HealthAttemptOutcome.HEALTHY
474
+ reason = f"HTTP {status_code}"
475
+ elif 300 <= status_code < 400:
476
+ outcome = HealthAttemptOutcome.REDIRECT_REFUSED
477
+ reason = f"HTTP {status_code} redirect was refused"
478
+ else:
479
+ outcome = HealthAttemptOutcome.UNHEALTHY_HTTP
480
+ reason = f"HTTP {status_code} was not healthy"
481
+ return self._attempt(attempt, outcome, status_code, body, reason, started_at)
482
+
483
+ def _attempt(
484
+ self,
485
+ attempt: int,
486
+ outcome: HealthAttemptOutcome,
487
+ status_code: int | None,
488
+ body: BoundedResponseEvidence | None,
489
+ reason: str,
490
+ started_at: float,
491
+ ) -> HealthAttemptEvidence:
492
+ return HealthAttemptEvidence(
493
+ attempt=attempt,
494
+ outcome=outcome,
495
+ status_code=status_code,
496
+ body=body,
497
+ reason=self.secrets.redact_text(reason),
498
+ duration_seconds=self._duration(started_at),
499
+ )
500
+
501
+ def _failed_readiness(
502
+ self,
503
+ *,
504
+ safe_url: str,
505
+ attempts: int,
506
+ last_attempt: HealthAttemptEvidence | None,
507
+ started_at: float,
508
+ ) -> ReadinessEvidence:
509
+ last_reason = "no request completed" if last_attempt is None else last_attempt.reason
510
+ return ReadinessEvidence(
511
+ url=safe_url,
512
+ healthy=False,
513
+ attempt_count=attempts,
514
+ last_attempt=last_attempt,
515
+ duration_seconds=self._duration(started_at),
516
+ reason=(
517
+ "candidate readiness deadline expired after "
518
+ f"{self.application.startup_timeout_seconds:g} seconds; "
519
+ f"last result: {last_reason}"
520
+ ),
521
+ )
522
+
523
+ def _duration(self, started_at: float) -> float:
524
+ return max(0.0, self._clock() - started_at)
525
+
526
+
527
+ class CandidateHealthChecker(ApplicationHealthChecker):
528
+ """Backward-compatible candidate health adapter over the generic boundary."""
529
+
530
+ def __init__(
531
+ self,
532
+ *,
533
+ application: ApplicationConfig,
534
+ database_environment_name: str,
535
+ secrets: SecretRegistry,
536
+ working_directory: Path,
537
+ command_environment: Mapping[str, str] | None = None,
538
+ command_runner: CommandExecutor | None = None,
539
+ client: httpx.Client | None = None,
540
+ transport: httpx.BaseTransport | None = None,
541
+ request_timeout_seconds: float = DEFAULT_REQUEST_TIMEOUT_SECONDS,
542
+ retry_interval_seconds: float = DEFAULT_RETRY_INTERVAL_SECONDS,
543
+ max_response_bytes: int = DEFAULT_MAX_RESPONSE_BYTES,
544
+ clock: Callable[[], float] = time.monotonic,
545
+ sleeper: Callable[[float], None] = time.sleep,
546
+ ) -> None:
547
+ super().__init__(
548
+ application=application,
549
+ health_url=application.candidate_health_url,
550
+ database_environment_name=database_environment_name,
551
+ secrets=secrets,
552
+ working_directory=working_directory,
553
+ smoke_environment=application.test_mode_env,
554
+ health_url_environment_name=CANDIDATE_URL_ENV,
555
+ command_environment=command_environment,
556
+ command_runner=command_runner,
557
+ client=client,
558
+ transport=transport,
559
+ request_timeout_seconds=request_timeout_seconds,
560
+ retry_interval_seconds=retry_interval_seconds,
561
+ max_response_bytes=max_response_bytes,
562
+ clock=clock,
563
+ sleeper=sleeper,
564
+ )
565
+
566
+ def check(
567
+ self,
568
+ *,
569
+ version: str,
570
+ rehearsal_database_path: Path,
571
+ cancellation_event: threading.Event | None = None,
572
+ ) -> CandidateHealthResult:
573
+ return self.check_application(
574
+ version=version,
575
+ database_path=rehearsal_database_path,
576
+ cancellation_event=cancellation_event,
577
+ )
578
+
579
+
580
+ def _smoke_failure(result: CommandResult) -> str | None:
581
+ if result.stdout.truncated or result.stderr.truncated:
582
+ return "candidate smoke output exceeded the complete-capture safety bound"
583
+ if result.outcome is CommandOutcome.SUCCEEDED:
584
+ return None
585
+ if result.outcome is CommandOutcome.NONZERO_EXIT:
586
+ return f"candidate smoke command exited with status {result.exit_code}"
587
+ if result.outcome is CommandOutcome.TIMED_OUT:
588
+ return "candidate smoke command timed out and its process group was terminated"
589
+ if result.outcome is CommandOutcome.CANCELLED:
590
+ return "candidate smoke command was cancelled and its process group was terminated"
591
+ if result.outcome is CommandOutcome.CLEANUP_FAILED:
592
+ return "candidate smoke process cleanup could not be proven: " + (
593
+ result.cleanup_error or "unknown cleanup failure"
594
+ )
595
+ return "candidate smoke command could not start: " + (
596
+ result.start_error or "unknown start failure"
597
+ )
598
+
599
+
600
+ def _absolute_existing_file(path: Path) -> Path:
601
+ if not isinstance(path, Path):
602
+ raise TypeError("rehearsal_database_path must be a pathlib.Path")
603
+ if not path.is_absolute():
604
+ raise ValueError("rehearsal_database_path must be absolute")
605
+ resolved = path.resolve()
606
+ if not resolved.is_file():
607
+ raise ValueError("rehearsal_database_path must identify an existing file")
608
+ return resolved
609
+
610
+
611
+ def _positive_seconds(value: float, name: str) -> float:
612
+ if isinstance(value, bool) or not isinstance(value, int | float):
613
+ raise TypeError(f"{name} must be a number")
614
+ result = float(value)
615
+ if not math.isfinite(result) or result <= 0:
616
+ raise ValueError(f"{name} must be finite and greater than zero")
617
+ return result
618
+
619
+
620
+ def _positive_integer(value: int, name: str) -> int:
621
+ if isinstance(value, bool) or not isinstance(value, int):
622
+ raise TypeError(f"{name} must be an integer")
623
+ if value <= 0:
624
+ raise ValueError(f"{name} must be greater than zero")
625
+ return value