ci-time-tracker 0.2.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.
- ci_time_tracker/__init__.py +3 -0
- ci_time_tracker/__main__.py +12 -0
- ci_time_tracker/analyzer.py +444 -0
- ci_time_tracker/cache.py +113 -0
- ci_time_tracker/cli.py +664 -0
- ci_time_tracker/config_parser.py +484 -0
- ci_time_tracker/detection.py +234 -0
- ci_time_tracker/gates.py +108 -0
- ci_time_tracker/github_api.py +624 -0
- ci_time_tracker/impact.py +361 -0
- ci_time_tracker/log_parser.py +728 -0
- ci_time_tracker/models.py +277 -0
- ci_time_tracker/pricing.py +202 -0
- ci_time_tracker/reporter.py +577 -0
- ci_time_tracker-0.2.0.dist-info/METADATA +561 -0
- ci_time_tracker-0.2.0.dist-info/RECORD +20 -0
- ci_time_tracker-0.2.0.dist-info/WHEEL +5 -0
- ci_time_tracker-0.2.0.dist-info/entry_points.txt +2 -0
- ci_time_tracker-0.2.0.dist-info/licenses/LICENSE +21 -0
- ci_time_tracker-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""Data models for ci-time-tracker.
|
|
2
|
+
|
|
3
|
+
This module contains all dataclasses used across the application for
|
|
4
|
+
representing pipeline configurations, build logs, statistics, and reports.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from typing import Any, Literal
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class PipelineStep:
|
|
14
|
+
"""A discrete unit of work within a CI/CD pipeline.
|
|
15
|
+
|
|
16
|
+
Attributes:
|
|
17
|
+
name: The step identifier/name
|
|
18
|
+
stage: Optional stage grouping for the step
|
|
19
|
+
command: Optional command or script executed by the step
|
|
20
|
+
estimated_duration: Optional estimated runtime in seconds
|
|
21
|
+
"""
|
|
22
|
+
name: str
|
|
23
|
+
stage: str | None = None
|
|
24
|
+
command: str | None = None
|
|
25
|
+
estimated_duration: float | None = None
|
|
26
|
+
|
|
27
|
+
def validate(self) -> list[str]:
|
|
28
|
+
"""Validate the step data and return list of validation errors."""
|
|
29
|
+
errors = []
|
|
30
|
+
if not self.name or not self.name.strip():
|
|
31
|
+
errors.append("Step name cannot be empty")
|
|
32
|
+
if self.estimated_duration is not None and self.estimated_duration < 0:
|
|
33
|
+
errors.append("Estimated duration cannot be negative")
|
|
34
|
+
return errors
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class PipelineConfig:
|
|
39
|
+
"""Parsed CI/CD pipeline configuration.
|
|
40
|
+
|
|
41
|
+
Attributes:
|
|
42
|
+
provider: CI provider name (github, gitlab, circleci)
|
|
43
|
+
steps: List of pipeline steps
|
|
44
|
+
stages: List of stage names in order
|
|
45
|
+
metadata: Additional provider-specific metadata
|
|
46
|
+
"""
|
|
47
|
+
provider: str
|
|
48
|
+
steps: list[PipelineStep] = field(default_factory=list)
|
|
49
|
+
stages: list[str] = field(default_factory=list)
|
|
50
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
51
|
+
|
|
52
|
+
def validate(self) -> list[str]:
|
|
53
|
+
"""Validate the config and return list of validation errors."""
|
|
54
|
+
errors = []
|
|
55
|
+
if not self.provider or not self.provider.strip():
|
|
56
|
+
errors.append("Provider cannot be empty")
|
|
57
|
+
for step in self.steps:
|
|
58
|
+
step_errors = step.validate()
|
|
59
|
+
errors.extend(step_errors)
|
|
60
|
+
return errors
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class StepExecution:
|
|
65
|
+
"""A single execution of a pipeline step from a build log.
|
|
66
|
+
|
|
67
|
+
Attributes:
|
|
68
|
+
name: The step identifier/name
|
|
69
|
+
start_time: When the step started executing
|
|
70
|
+
end_time: When the step finished executing
|
|
71
|
+
duration_seconds: Computed or extracted duration in seconds
|
|
72
|
+
status: Execution result (success, failure, skipped)
|
|
73
|
+
is_retry: Whether this execution was a retry attempt
|
|
74
|
+
job_name: Name of the job this step ran in, when known
|
|
75
|
+
runner: Label or name of the machine that ran the step, when known
|
|
76
|
+
"""
|
|
77
|
+
name: str
|
|
78
|
+
start_time: datetime | None = None
|
|
79
|
+
end_time: datetime | None = None
|
|
80
|
+
duration_seconds: float | None = None
|
|
81
|
+
status: Literal["success", "failure", "skipped"] = "success"
|
|
82
|
+
is_retry: bool = False
|
|
83
|
+
job_name: str | None = None
|
|
84
|
+
runner: str | None = None
|
|
85
|
+
|
|
86
|
+
def validate(self) -> list[str]:
|
|
87
|
+
"""Validate the step execution and return list of validation errors."""
|
|
88
|
+
errors = []
|
|
89
|
+
if not self.name or not self.name.strip():
|
|
90
|
+
errors.append("Step name cannot be empty")
|
|
91
|
+
if self.duration_seconds is not None and self.duration_seconds < 0:
|
|
92
|
+
errors.append("Duration cannot be negative")
|
|
93
|
+
if self.start_time and self.end_time and self.start_time > self.end_time:
|
|
94
|
+
errors.append("Start time cannot be after end time")
|
|
95
|
+
return errors
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass
|
|
99
|
+
class BuildLog:
|
|
100
|
+
"""Parsed build log containing step executions.
|
|
101
|
+
|
|
102
|
+
Attributes:
|
|
103
|
+
build_id: Optional identifier for the build
|
|
104
|
+
steps: List of step executions from the log
|
|
105
|
+
total_duration: Total build duration in seconds
|
|
106
|
+
timestamp: When the build occurred
|
|
107
|
+
head_sha: Commit the build ran against, when known
|
|
108
|
+
run_attempt: Attempt number of the build (1 unless it was re-run)
|
|
109
|
+
conclusion: Overall build result as reported by the provider
|
|
110
|
+
metadata: Additional provider-specific data (jobs, queue times, ...)
|
|
111
|
+
"""
|
|
112
|
+
build_id: str | None = None
|
|
113
|
+
steps: list[StepExecution] = field(default_factory=list)
|
|
114
|
+
total_duration: float | None = None
|
|
115
|
+
timestamp: datetime | None = None
|
|
116
|
+
head_sha: str | None = None
|
|
117
|
+
run_attempt: int = 1
|
|
118
|
+
conclusion: str | None = None
|
|
119
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
120
|
+
|
|
121
|
+
def validate(self) -> list[str]:
|
|
122
|
+
"""Validate the build log and return list of validation errors."""
|
|
123
|
+
errors = []
|
|
124
|
+
if self.total_duration is not None and self.total_duration < 0:
|
|
125
|
+
errors.append("Total duration cannot be negative")
|
|
126
|
+
for step in self.steps:
|
|
127
|
+
step_errors = step.validate()
|
|
128
|
+
errors.extend(step_errors)
|
|
129
|
+
return errors
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass
|
|
133
|
+
class StepStatistics:
|
|
134
|
+
"""Aggregated statistics for a pipeline step across multiple builds.
|
|
135
|
+
|
|
136
|
+
Attributes:
|
|
137
|
+
name: The step identifier/name
|
|
138
|
+
execution_count: Total number of executions
|
|
139
|
+
success_count: Number of successful executions
|
|
140
|
+
failure_count: Number of failed executions
|
|
141
|
+
durations: List of recorded durations in seconds
|
|
142
|
+
p50: 50th percentile duration
|
|
143
|
+
p90: 90th percentile duration
|
|
144
|
+
p95: 95th percentile duration
|
|
145
|
+
p99: 99th percentile duration
|
|
146
|
+
is_regression: Whether the step's duration regressed
|
|
147
|
+
is_flaky: Whether the step is flagged as flaky
|
|
148
|
+
failure_rate: Ratio of failures to total executions
|
|
149
|
+
total_duration: Sum of all recorded durations, i.e. the time this step
|
|
150
|
+
has consumed across every build
|
|
151
|
+
mean_duration: Average recorded duration
|
|
152
|
+
runner: Runner the step ran on, when known
|
|
153
|
+
estimated_cost_usd: Cost attributed to this step, when the runner's
|
|
154
|
+
rate is known
|
|
155
|
+
regression: Details of the duration regression, when there is one
|
|
156
|
+
flaky_evidence: Why the step was flagged as flaky, when it was
|
|
157
|
+
"""
|
|
158
|
+
name: str
|
|
159
|
+
execution_count: int = 0
|
|
160
|
+
success_count: int = 0
|
|
161
|
+
failure_count: int = 0
|
|
162
|
+
durations: list[float] = field(default_factory=list)
|
|
163
|
+
p50: float | None = None
|
|
164
|
+
p90: float | None = None
|
|
165
|
+
p95: float | None = None
|
|
166
|
+
p99: float | None = None
|
|
167
|
+
is_regression: bool = False
|
|
168
|
+
is_flaky: bool = False
|
|
169
|
+
failure_rate: float = 0.0
|
|
170
|
+
total_duration: float = 0.0
|
|
171
|
+
mean_duration: float | None = None
|
|
172
|
+
runner: str | None = None
|
|
173
|
+
estimated_cost_usd: float | None = None
|
|
174
|
+
regression: dict[str, Any] | None = None
|
|
175
|
+
flaky_evidence: dict[str, Any] | None = None
|
|
176
|
+
|
|
177
|
+
def validate(self) -> list[str]:
|
|
178
|
+
"""Validate the statistics and return list of validation errors."""
|
|
179
|
+
errors = []
|
|
180
|
+
if not self.name or not self.name.strip():
|
|
181
|
+
errors.append("Step name cannot be empty")
|
|
182
|
+
if self.execution_count < 0:
|
|
183
|
+
errors.append("Execution count cannot be negative")
|
|
184
|
+
if self.success_count < 0:
|
|
185
|
+
errors.append("Success count cannot be negative")
|
|
186
|
+
if self.failure_count < 0:
|
|
187
|
+
errors.append("Failure count cannot be negative")
|
|
188
|
+
if self.success_count + self.failure_count > self.execution_count:
|
|
189
|
+
errors.append("Success + failure count cannot exceed execution count")
|
|
190
|
+
if not (0.0 <= self.failure_rate <= 1.0):
|
|
191
|
+
errors.append("Failure rate must be between 0 and 1")
|
|
192
|
+
for d in self.durations:
|
|
193
|
+
if d < 0:
|
|
194
|
+
errors.append("Duration values cannot be negative")
|
|
195
|
+
break
|
|
196
|
+
return errors
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@dataclass
|
|
200
|
+
class AnalysisResult:
|
|
201
|
+
"""Result of analyzing pipeline config or build logs.
|
|
202
|
+
|
|
203
|
+
Attributes:
|
|
204
|
+
mode: Analysis mode (config or log)
|
|
205
|
+
steps: List of step statistics (log mode) or pipeline steps (config mode)
|
|
206
|
+
total_estimated_duration: Sum of estimated durations for config mode
|
|
207
|
+
regressed_steps: Names of steps whose duration regressed
|
|
208
|
+
flaky_steps: Names of steps flagged as flaky
|
|
209
|
+
metadata: Additional analysis metadata
|
|
210
|
+
"""
|
|
211
|
+
mode: Literal["config", "log"]
|
|
212
|
+
steps: list[StepStatistics] | list[PipelineStep] = field(default_factory=list)
|
|
213
|
+
total_estimated_duration: float | None = None
|
|
214
|
+
regressed_steps: list[str] = field(default_factory=list)
|
|
215
|
+
flaky_steps: list[str] = field(default_factory=list)
|
|
216
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
217
|
+
|
|
218
|
+
def validate(self) -> list[str]:
|
|
219
|
+
"""Validate the analysis result and return list of validation errors."""
|
|
220
|
+
errors = []
|
|
221
|
+
if self.mode not in ("config", "log"):
|
|
222
|
+
errors.append("Mode must be 'config' or 'log'")
|
|
223
|
+
if self.total_estimated_duration is not None and self.total_estimated_duration < 0:
|
|
224
|
+
errors.append("Total estimated duration cannot be negative")
|
|
225
|
+
return errors
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
@dataclass
|
|
229
|
+
class Report:
|
|
230
|
+
"""Final report generated from analysis results.
|
|
231
|
+
|
|
232
|
+
Attributes:
|
|
233
|
+
title: Report title
|
|
234
|
+
mode: Analysis mode that produced this report
|
|
235
|
+
generated_at: Timestamp when report was generated
|
|
236
|
+
summary: Summary statistics and counts
|
|
237
|
+
steps: List of step data dictionaries
|
|
238
|
+
issues: List of detected issues (regressions, flaky steps)
|
|
239
|
+
"""
|
|
240
|
+
title: str
|
|
241
|
+
mode: str
|
|
242
|
+
generated_at: datetime
|
|
243
|
+
summary: dict[str, Any] = field(default_factory=dict)
|
|
244
|
+
steps: list[dict[str, Any]] = field(default_factory=list)
|
|
245
|
+
issues: list[dict[str, Any]] = field(default_factory=list)
|
|
246
|
+
|
|
247
|
+
def validate(self) -> list[str]:
|
|
248
|
+
"""Validate the report and return list of validation errors."""
|
|
249
|
+
errors = []
|
|
250
|
+
if not self.title or not self.title.strip():
|
|
251
|
+
errors.append("Report title cannot be empty")
|
|
252
|
+
if not self.mode or not self.mode.strip():
|
|
253
|
+
errors.append("Report mode cannot be empty")
|
|
254
|
+
return errors
|
|
255
|
+
|
|
256
|
+
def to_dict(self) -> dict[str, Any]:
|
|
257
|
+
"""Convert report to dictionary for JSON serialization."""
|
|
258
|
+
return {
|
|
259
|
+
"title": self.title,
|
|
260
|
+
"mode": self.mode,
|
|
261
|
+
"generated_at": self.generated_at.isoformat(),
|
|
262
|
+
"summary": self.summary,
|
|
263
|
+
"steps": self.steps,
|
|
264
|
+
"issues": self.issues,
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
@classmethod
|
|
268
|
+
def from_dict(cls, data: dict[str, Any]) -> "Report":
|
|
269
|
+
"""Create Report from dictionary (JSON deserialization)."""
|
|
270
|
+
return cls(
|
|
271
|
+
title=data["title"],
|
|
272
|
+
mode=data["mode"],
|
|
273
|
+
generated_at=datetime.fromisoformat(data["generated_at"]),
|
|
274
|
+
summary=data.get("summary", {}),
|
|
275
|
+
steps=data.get("steps", []),
|
|
276
|
+
issues=data.get("issues", []),
|
|
277
|
+
)
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Runner pricing for cost estimates.
|
|
2
|
+
|
|
3
|
+
The defaults are GitHub's list prices for private repositories, as published at
|
|
4
|
+
PRICES_SOURCE_URL on PRICES_AS_OF. Public repositories pay nothing for
|
|
5
|
+
standard GitHub-hosted runners, and self-hosted runners are free. Prices
|
|
6
|
+
change, so they can be overridden with a JSON file (see load_pricing).
|
|
7
|
+
|
|
8
|
+
Each job's cost is its duration rounded up to a whole minute. GitHub has
|
|
9
|
+
billed that way; its current documentation does not state the rounding.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import math
|
|
14
|
+
import re
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
PRICES_AS_OF = "2026-10"
|
|
20
|
+
PRICES_SOURCE_URL = "https://docs.github.com/en/billing/reference/actions-runner-pricing"
|
|
21
|
+
|
|
22
|
+
# USD per minute for the labels that are not versioned (versioned labels such
|
|
23
|
+
# as ubuntu-24.04 or macos-15 are matched by _LABEL_FAMILIES below)
|
|
24
|
+
DEFAULT_PRICES_USD_PER_MINUTE: dict[str, float] = {
|
|
25
|
+
"ubuntu-latest": 0.006,
|
|
26
|
+
"ubuntu-slim": 0.002,
|
|
27
|
+
"windows-latest": 0.010,
|
|
28
|
+
"macos-latest": 0.062,
|
|
29
|
+
"macos-latest-large": 0.077,
|
|
30
|
+
"macos-latest-xlarge": 0.102,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
# GitHub-hosted label families, most specific first, so that labels GitHub
|
|
34
|
+
# adds later (ubuntu-26.04, macos-26-intel, ...) are priced without an update
|
|
35
|
+
_LABEL_FAMILIES: list[tuple[re.Pattern[str], float]] = [
|
|
36
|
+
(re.compile(r"^ubuntu-(latest|\d+\.\d+)-arm$"), 0.005), # Linux 2-core arm64
|
|
37
|
+
(re.compile(r"^ubuntu-(latest|\d+\.\d+)$"), 0.006), # Linux 2-core x64
|
|
38
|
+
(re.compile(r"^windows-(latest|\d+)-arm$"), 0.010), # Windows 2-core arm64
|
|
39
|
+
(re.compile(r"^windows-(latest|\d{4})$"), 0.010), # Windows 2-core x64
|
|
40
|
+
(re.compile(r"^macos-(latest|\d+)-xlarge$"), 0.102), # macOS 5-core M2 Pro
|
|
41
|
+
(re.compile(r"^macos-(latest|\d+)-large$"), 0.077), # macOS 12-core Intel
|
|
42
|
+
(re.compile(r"^macos-(latest|\d+)(-intel)?$"), 0.062), # macOS 3/4-core
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
# Larger runners are named freely (e.g. "ubuntu-24.04-8-cores"), so they are
|
|
46
|
+
# priced from the platform, architecture and core count in the label. The
|
|
47
|
+
# published rates do not scale linearly with cores, hence the tables; a core
|
|
48
|
+
# count that is not published stays unpriced rather than extrapolated.
|
|
49
|
+
_LARGER_RUNNER_PRICES: dict[tuple[str, str], dict[int, float]] = {
|
|
50
|
+
("linux", "x64"): {2: 0.006, 4: 0.012, 8: 0.022, 16: 0.042, 32: 0.082, 64: 0.162, 96: 0.252},
|
|
51
|
+
("windows", "x64"): {4: 0.022, 8: 0.042, 16: 0.082, 32: 0.162, 64: 0.322, 96: 0.552},
|
|
52
|
+
("linux", "arm64"): {2: 0.005, 4: 0.008, 8: 0.014, 16: 0.026, 32: 0.050, 64: 0.098},
|
|
53
|
+
("windows", "arm64"): {2: 0.008, 4: 0.014, 8: 0.026, 16: 0.050, 32: 0.098, 64: 0.194},
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
_CORES_PATTERN = re.compile(r"(\d+)[-_ ]?cores?\b", re.IGNORECASE)
|
|
57
|
+
|
|
58
|
+
# A self-hosted runner costs the user nothing on GitHub's bill.
|
|
59
|
+
SELF_HOSTED_LABELS = frozenset({"self-hosted", "selfhosted"})
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class PricingTable:
|
|
64
|
+
"""Maps runner labels to a USD-per-minute rate.
|
|
65
|
+
|
|
66
|
+
Attributes:
|
|
67
|
+
prices: Runner label (lowercased) to USD per minute; checked before
|
|
68
|
+
the label families and larger-runner tables, so a file can
|
|
69
|
+
override any of them
|
|
70
|
+
source: Where the rates came from, for the report
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
prices: dict[str, float] = field(
|
|
74
|
+
default_factory=lambda: dict(DEFAULT_PRICES_USD_PER_MINUTE)
|
|
75
|
+
)
|
|
76
|
+
source: str = f"GitHub list prices as of {PRICES_AS_OF}"
|
|
77
|
+
# Resolved rates by runner string: an analysis asks once per step
|
|
78
|
+
# execution, for a handful of distinct runners
|
|
79
|
+
_resolved: dict[str | None, float | None] = field(
|
|
80
|
+
default_factory=dict, init=False, repr=False, compare=False
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
def price_for(self, runner: str | None) -> float | None:
|
|
84
|
+
"""Return the USD-per-minute rate for a runner, or None if unknown.
|
|
85
|
+
|
|
86
|
+
`runner` may be a comma-separated label list, as GitHub reports it.
|
|
87
|
+
Unknown runners return None rather than 0, so that an incomplete
|
|
88
|
+
estimate is never presented as a complete one.
|
|
89
|
+
"""
|
|
90
|
+
if runner not in self._resolved:
|
|
91
|
+
self._resolved[runner] = self._resolve(runner)
|
|
92
|
+
return self._resolved[runner]
|
|
93
|
+
|
|
94
|
+
def _resolve(self, runner: str | None) -> float | None:
|
|
95
|
+
"""Look up a runner's rate, without caching."""
|
|
96
|
+
if not runner:
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
labels = [label.strip().lower() for label in runner.split(",") if label.strip()]
|
|
100
|
+
if not labels:
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
# A self-hosted job is not billed, whatever else it is labelled with
|
|
104
|
+
if any(label in SELF_HOSTED_LABELS for label in labels):
|
|
105
|
+
return 0.0
|
|
106
|
+
|
|
107
|
+
for label in labels:
|
|
108
|
+
if label in self.prices:
|
|
109
|
+
return self.prices[label]
|
|
110
|
+
|
|
111
|
+
for label in labels:
|
|
112
|
+
for pattern, price in _LABEL_FAMILIES:
|
|
113
|
+
if pattern.match(label):
|
|
114
|
+
return price
|
|
115
|
+
|
|
116
|
+
for label in labels:
|
|
117
|
+
derived = _price_from_core_count(label)
|
|
118
|
+
if derived is not None:
|
|
119
|
+
return derived
|
|
120
|
+
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _price_from_core_count(label: str) -> float | None:
|
|
125
|
+
"""Price a larger runner from the platform and core count in its label."""
|
|
126
|
+
cores_match = _CORES_PATTERN.search(label)
|
|
127
|
+
if not cores_match:
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
if "windows" in label:
|
|
131
|
+
platform = "windows"
|
|
132
|
+
elif "ubuntu" in label or "linux" in label:
|
|
133
|
+
platform = "linux"
|
|
134
|
+
else:
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
architecture = "arm64" if "arm" in label else "x64"
|
|
138
|
+
return _LARGER_RUNNER_PRICES[(platform, architecture)].get(int(cores_match.group(1)))
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def load_pricing(path: str | Path) -> PricingTable:
|
|
142
|
+
"""Load a runner pricing table from a JSON file.
|
|
143
|
+
|
|
144
|
+
The file maps runner labels to USD per minute:
|
|
145
|
+
|
|
146
|
+
{"ubuntu-latest": 0.006, "my-big-runner": 0.032}
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
path: Path to the JSON file
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
PricingTable with the file's rates taking precedence over the defaults
|
|
153
|
+
|
|
154
|
+
Raises:
|
|
155
|
+
ValueError: If the file is not a JSON object of non-negative numbers
|
|
156
|
+
OSError: If the file cannot be read
|
|
157
|
+
"""
|
|
158
|
+
content = Path(path).read_text(encoding="utf-8")
|
|
159
|
+
|
|
160
|
+
try:
|
|
161
|
+
data: Any = json.loads(content)
|
|
162
|
+
except json.JSONDecodeError as error:
|
|
163
|
+
raise ValueError(f"Invalid JSON in pricing file: {error.msg}") from error
|
|
164
|
+
|
|
165
|
+
if not isinstance(data, dict):
|
|
166
|
+
raise ValueError("Pricing file must contain a JSON object of label -> USD per minute")
|
|
167
|
+
|
|
168
|
+
prices = dict(DEFAULT_PRICES_USD_PER_MINUTE)
|
|
169
|
+
|
|
170
|
+
for label, price in data.items():
|
|
171
|
+
if isinstance(price, bool) or not isinstance(price, (int, float)):
|
|
172
|
+
raise ValueError(f"Price for '{label}' must be a number, got {type(price).__name__}")
|
|
173
|
+
if price < 0:
|
|
174
|
+
raise ValueError(f"Price for '{label}' cannot be negative")
|
|
175
|
+
prices[str(label).strip().lower()] = float(price)
|
|
176
|
+
|
|
177
|
+
return PricingTable(prices=prices, source=str(path))
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def job_cost(duration_seconds: float | None, price_per_minute: float | None) -> float | None:
|
|
181
|
+
"""Cost of one job, its duration rounded up to a whole minute.
|
|
182
|
+
|
|
183
|
+
Returns None when either the duration or the rate is unknown.
|
|
184
|
+
"""
|
|
185
|
+
if duration_seconds is None or price_per_minute is None:
|
|
186
|
+
return None
|
|
187
|
+
if duration_seconds <= 0:
|
|
188
|
+
return 0.0
|
|
189
|
+
|
|
190
|
+
return math.ceil(duration_seconds / 60.0) * price_per_minute
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def attributed_cost(duration_seconds: float | None, price_per_minute: float | None) -> float | None:
|
|
194
|
+
"""Cost attributed to a step, without per-job rounding.
|
|
195
|
+
|
|
196
|
+
Used to rank steps by spend. The sum over steps is therefore slightly
|
|
197
|
+
below the job total, which rounds each job up to a whole minute.
|
|
198
|
+
"""
|
|
199
|
+
if duration_seconds is None or price_per_minute is None:
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
return (duration_seconds / 60.0) * price_per_minute
|