ghinsight 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.
- ghinsight/__init__.py +32 -0
- ghinsight/actions/__init__.py +41 -0
- ghinsight/actions/analysis.py +201 -0
- ghinsight/actions/billing.py +195 -0
- ghinsight/actions/charts.py +439 -0
- ghinsight/actions/cli.py +283 -0
- ghinsight/actions/compare.py +323 -0
- ghinsight/actions/config.py +189 -0
- ghinsight/actions/csv_export.py +60 -0
- ghinsight/actions/dataset.py +271 -0
- ghinsight/actions/domain.py +360 -0
- ghinsight/actions/fetch.py +317 -0
- ghinsight/actions/metrics.py +287 -0
- ghinsight/actions/report.py +655 -0
- ghinsight/activity.py +589 -0
- ghinsight/cli.py +122 -0
- ghinsight/client.py +321 -0
- ghinsight/repos.py +104 -0
- ghinsight-0.2.0.dist-info/METADATA +249 -0
- ghinsight-0.2.0.dist-info/RECORD +23 -0
- ghinsight-0.2.0.dist-info/WHEEL +4 -0
- ghinsight-0.2.0.dist-info/entry_points.txt +2 -0
- ghinsight-0.2.0.dist-info/licenses/LICENSE +29 -0
ghinsight/__init__.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""ghinsight (pronounced "gin-sight"): reusable tools for GitHub analysis.
|
|
2
|
+
|
|
3
|
+
The core needs only httpx2 and typer:
|
|
4
|
+
|
|
5
|
+
- :mod:`ghinsight.client`: GitHub REST/GraphQL client and token resolution;
|
|
6
|
+
- :mod:`ghinsight.repos`: repositories of an organisation or user;
|
|
7
|
+
- :mod:`ghinsight.activity`: a user's issue and pull request activity.
|
|
8
|
+
|
|
9
|
+
:mod:`ghinsight.actions` (GitHub Actions usage and performance, with a
|
|
10
|
+
dashboard) needs the ``actions`` extra: ``pip install 'ghinsight[actions]'``.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
16
|
+
|
|
17
|
+
from .client import GitHubClient, GitHubError, resolve_token
|
|
18
|
+
from .repos import RepoInfo, list_repos
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
__version__ = version("ghinsight")
|
|
22
|
+
except PackageNotFoundError: # pragma: no cover - running from a bare checkout
|
|
23
|
+
__version__ = "0+unknown"
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"GitHubClient",
|
|
27
|
+
"GitHubError",
|
|
28
|
+
"RepoInfo",
|
|
29
|
+
"__version__",
|
|
30
|
+
"list_repos",
|
|
31
|
+
"resolve_token",
|
|
32
|
+
]
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""GitHub Actions usage and performance analysis (the ``actions`` extra).
|
|
2
|
+
|
|
3
|
+
A three-stage, functional pipeline:
|
|
4
|
+
|
|
5
|
+
1. **fetch** (:mod:`ghinsight.actions.fetch`) — cache raw runs/jobs from GitHub.
|
|
6
|
+
2. **dataset** (:mod:`ghinsight.actions.dataset`) — parse the cache into the
|
|
7
|
+
canonical tidy intermediate representation, :class:`ActionsDataset`.
|
|
8
|
+
3. **analysis** — derive metric tables (:mod:`ghinsight.actions.metrics`),
|
|
9
|
+
insights (:mod:`ghinsight.actions.analysis`), and a report
|
|
10
|
+
(:mod:`ghinsight.actions.report`) — all pure projections of the dataset.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from .analysis import UsageSummary, summarize_usage
|
|
16
|
+
from .config import AnalysisConfig, DateRange, load_config, parse_period
|
|
17
|
+
from .csv_export import write_reports
|
|
18
|
+
from .dataset import ActionsDataset
|
|
19
|
+
from .domain import Job, Run, RunnerType, RuntimeOS, Visibility
|
|
20
|
+
from .metrics import performance_table, usage_table
|
|
21
|
+
from .report import build_dashboard, render_html
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"ActionsDataset",
|
|
25
|
+
"AnalysisConfig",
|
|
26
|
+
"DateRange",
|
|
27
|
+
"Job",
|
|
28
|
+
"Run",
|
|
29
|
+
"RunnerType",
|
|
30
|
+
"RuntimeOS",
|
|
31
|
+
"UsageSummary",
|
|
32
|
+
"Visibility",
|
|
33
|
+
"build_dashboard",
|
|
34
|
+
"load_config",
|
|
35
|
+
"parse_period",
|
|
36
|
+
"performance_table",
|
|
37
|
+
"render_html",
|
|
38
|
+
"summarize_usage",
|
|
39
|
+
"usage_table",
|
|
40
|
+
"write_reports",
|
|
41
|
+
]
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Stage 3b — derived insights, as pure functions over the dataset.
|
|
2
|
+
|
|
3
|
+
This module contains the *interpretation* layer: billed-equivalent minutes,
|
|
4
|
+
plan-cap projections and monthly time-series. It produces plain DataFrames and
|
|
5
|
+
an immutable :class:`UsageSummary`, with no plotting or HTML concerns, so it is
|
|
6
|
+
the natural entry point for ad-hoc analysis (see the Python tutorial) as well as
|
|
7
|
+
the data source for the dashboard.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Mapping
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
|
|
15
|
+
import pandas as pd
|
|
16
|
+
|
|
17
|
+
from .config import AnalysisConfig
|
|
18
|
+
from .dataset import ActionsDataset
|
|
19
|
+
from .metrics import usage_table
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"UsageSummary",
|
|
23
|
+
"billed_equivalent_by_os",
|
|
24
|
+
"billing_monthly_usage",
|
|
25
|
+
"monthly_comparison",
|
|
26
|
+
"monthly_usage",
|
|
27
|
+
"summarize_usage",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
MONTHS_PER_YEAR = 12
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _multiplier_series(os_column: pd.Series, multipliers: Mapping[str, float]) -> pd.Series:
|
|
34
|
+
return os_column.astype(str).str.lower().map(multipliers).fillna(1.0)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def billed_equivalent_by_os(
|
|
38
|
+
dataset: ActionsDataset, multipliers: Mapping[str, float]
|
|
39
|
+
) -> pd.DataFrame:
|
|
40
|
+
"""Per-OS usage with the configured billing multiplier applied.
|
|
41
|
+
|
|
42
|
+
Columns: ``Runtime OS``, ``Total minutes``, ``Multiplier``,
|
|
43
|
+
``Billed equivalent minutes``.
|
|
44
|
+
"""
|
|
45
|
+
table = usage_table(dataset, "runtime-os").copy()
|
|
46
|
+
if table.empty:
|
|
47
|
+
return pd.DataFrame(
|
|
48
|
+
columns=["Runtime OS", "Total minutes", "Multiplier", "Billed equivalent minutes"]
|
|
49
|
+
)
|
|
50
|
+
table["Multiplier"] = _multiplier_series(table["Runtime OS"], multipliers)
|
|
51
|
+
table["Billed equivalent minutes"] = table["Total minutes"] * table["Multiplier"]
|
|
52
|
+
return table
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def monthly_usage(
|
|
56
|
+
dataset: ActionsDataset, multipliers: Mapping[str, float]
|
|
57
|
+
) -> pd.DataFrame:
|
|
58
|
+
"""Hosted jobs augmented with ``adj_billed`` (billed-equivalent minutes).
|
|
59
|
+
|
|
60
|
+
This is the long-form basis for every monthly chart: one row per hosted job
|
|
61
|
+
with its month, OS, repo, conclusion and multiplier-adjusted minutes.
|
|
62
|
+
"""
|
|
63
|
+
# Even an empty frame gets ``adj_billed``, so charts over an empty scope
|
|
64
|
+
# (e.g. private-only when every repository is public) still work.
|
|
65
|
+
frame = dataset.hosted_jobs_frame.copy()
|
|
66
|
+
frame["adj_billed"] = frame["billed_minutes"] * _multiplier_series(
|
|
67
|
+
frame["runtime_os"], multipliers
|
|
68
|
+
)
|
|
69
|
+
return frame
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def billing_monthly_usage(
|
|
73
|
+
billing_frame: pd.DataFrame, multipliers: Mapping[str, float]
|
|
74
|
+
) -> pd.DataFrame:
|
|
75
|
+
"""Billing-report Actions minutes augmented with ``adj_billed``.
|
|
76
|
+
|
|
77
|
+
``billing_frame`` is :meth:`BillingUsage.actions_minutes_frame
|
|
78
|
+
<ghinsight.actions.billing.BillingUsage.actions_minutes_frame>`. The result
|
|
79
|
+
has the same ``month``/``runtime_os``/``adj_billed`` columns as
|
|
80
|
+
:func:`monthly_usage`, so the same monthly charts apply to it.
|
|
81
|
+
"""
|
|
82
|
+
frame = billing_frame.copy()
|
|
83
|
+
frame["adj_billed"] = frame["minutes"].astype(float) * _multiplier_series(
|
|
84
|
+
frame["runtime_os"], multipliers
|
|
85
|
+
)
|
|
86
|
+
return frame
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _monthly_all_and_private(
|
|
90
|
+
frame: pd.DataFrame, column: str
|
|
91
|
+
) -> tuple[pd.Series, pd.Series]:
|
|
92
|
+
"""Monthly sums of ``column`` over all repos and over non-public repos."""
|
|
93
|
+
if frame.empty:
|
|
94
|
+
empty = pd.Series(dtype=float)
|
|
95
|
+
return empty, empty
|
|
96
|
+
all_repos = frame.groupby("month")[column].sum()
|
|
97
|
+
private = frame[frame["visibility"] != "public"].groupby("month")[column].sum()
|
|
98
|
+
return all_repos, private.reindex(all_repos.index, fill_value=0.0)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def monthly_comparison(
|
|
102
|
+
estimated: pd.DataFrame, billed: pd.DataFrame | None = None
|
|
103
|
+
) -> pd.DataFrame:
|
|
104
|
+
"""Monthly billed-equivalent minutes: estimates by scope, and as billed.
|
|
105
|
+
|
|
106
|
+
``estimated`` is :func:`monthly_usage`; its private-only column drops public
|
|
107
|
+
repos and keeps private, internal and unknown ones. ``billed`` (optional)
|
|
108
|
+
is :func:`billing_monthly_usage`, taken as is: the billing report already
|
|
109
|
+
omits free public-repository usage. Billing columns are present only when
|
|
110
|
+
``billed`` is given; months missing from one source are ``NaN``.
|
|
111
|
+
"""
|
|
112
|
+
est_all, est_private = _monthly_all_and_private(estimated, "adj_billed")
|
|
113
|
+
columns = {"Estimated, all repos": est_all, "Estimated, private only": est_private}
|
|
114
|
+
if billed is not None:
|
|
115
|
+
columns |= {
|
|
116
|
+
"Billed (GitHub report)": billed.groupby("month")["adj_billed"].sum(),
|
|
117
|
+
"Actions net charge (USD)": billed.groupby("month")["net_amount"].sum(),
|
|
118
|
+
}
|
|
119
|
+
table = pd.DataFrame(columns).sort_index()
|
|
120
|
+
table.index.name = "Month"
|
|
121
|
+
return table.reset_index()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass(frozen=True, slots=True)
|
|
125
|
+
class UsageSummary:
|
|
126
|
+
"""Headline usage figures for a period. All minutes are billed-equivalent
|
|
127
|
+
unless named ``raw``."""
|
|
128
|
+
|
|
129
|
+
org: str
|
|
130
|
+
period: str
|
|
131
|
+
period_label: str
|
|
132
|
+
raw_total_minutes: float
|
|
133
|
+
billed_equivalent_minutes: float
|
|
134
|
+
estimated_monthly_billed: float
|
|
135
|
+
estimated_daily_billed: float
|
|
136
|
+
plan_minutes: float | None
|
|
137
|
+
plan_pct: float | None
|
|
138
|
+
cap_reached: bool | None
|
|
139
|
+
estimated_unserved_billed: float
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def cap_status_text(self) -> str:
|
|
143
|
+
"""Human-readable description of the plan-cap situation (HTML-safe)."""
|
|
144
|
+
if self.plan_minutes is None:
|
|
145
|
+
return ""
|
|
146
|
+
if self.cap_reached:
|
|
147
|
+
day_cap_hit = (
|
|
148
|
+
self.plan_minutes / self.estimated_daily_billed
|
|
149
|
+
if self.estimated_daily_billed
|
|
150
|
+
else 0.0
|
|
151
|
+
)
|
|
152
|
+
return (
|
|
153
|
+
f"cap hit around day <b>{day_cap_hit:.1f}</b> of an average month, "
|
|
154
|
+
f"leaving <b>{self.estimated_unserved_billed:,.0f}</b> "
|
|
155
|
+
"billed-equivalent minutes unmet"
|
|
156
|
+
)
|
|
157
|
+
return (
|
|
158
|
+
f"cap <b>not reached</b> in an average month "
|
|
159
|
+
f"(average usage is {self.plan_pct:.0f}% of the "
|
|
160
|
+
f"{self.plan_minutes:,.0f}-minute cap)"
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def summarize_usage(dataset: ActionsDataset, config: AnalysisConfig) -> UsageSummary:
|
|
165
|
+
"""Compute headline KPIs from the dataset and config.
|
|
166
|
+
|
|
167
|
+
The selected period is treated as an annual window, so monthly demand is the
|
|
168
|
+
billed-equivalent total divided by twelve (this matches the original report).
|
|
169
|
+
"""
|
|
170
|
+
by_os = billed_equivalent_by_os(dataset, config.os_multipliers)
|
|
171
|
+
raw_total = float(by_os["Total minutes"].sum()) if not by_os.empty else 0.0
|
|
172
|
+
billed_total = (
|
|
173
|
+
float(by_os["Billed equivalent minutes"].sum()) if not by_os.empty else 0.0
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
est_monthly = billed_total / MONTHS_PER_YEAR
|
|
177
|
+
est_daily = est_monthly / config.days_per_month if config.days_per_month else 0.0
|
|
178
|
+
|
|
179
|
+
plan = config.plan_minutes
|
|
180
|
+
plan_pct: float | None = None
|
|
181
|
+
cap_reached: bool | None = None
|
|
182
|
+
unserved = 0.0
|
|
183
|
+
if plan:
|
|
184
|
+
plan_pct = est_monthly / plan * 100
|
|
185
|
+
cap_reached = est_monthly > plan
|
|
186
|
+
if cap_reached:
|
|
187
|
+
unserved = est_monthly - plan
|
|
188
|
+
|
|
189
|
+
return UsageSummary(
|
|
190
|
+
org=config.org,
|
|
191
|
+
period=config.period,
|
|
192
|
+
period_label=config.period_label,
|
|
193
|
+
raw_total_minutes=raw_total,
|
|
194
|
+
billed_equivalent_minutes=billed_total,
|
|
195
|
+
estimated_monthly_billed=est_monthly,
|
|
196
|
+
estimated_daily_billed=est_daily,
|
|
197
|
+
plan_minutes=plan,
|
|
198
|
+
plan_pct=plan_pct,
|
|
199
|
+
cap_reached=cap_reached,
|
|
200
|
+
estimated_unserved_billed=unserved,
|
|
201
|
+
)
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""GitHub's billing usage report, as an optional companion to the dataset.
|
|
2
|
+
|
|
3
|
+
The :class:`~ghinsight.actions.dataset.ActionsDataset` *estimates* billed minutes
|
|
4
|
+
from job timestamps. When the account's billing usage report has been cached
|
|
5
|
+
(see :func:`ghinsight.actions.fetch.fetch_billing_usage`), :class:`BillingUsage`
|
|
6
|
+
provides the minutes GitHub actually billed, per day, repository and SKU.
|
|
7
|
+
|
|
8
|
+
The report is optional. :meth:`BillingUsage.from_cache` returns ``None`` when
|
|
9
|
+
nothing was cached, and every consumer must treat that as "use the estimates
|
|
10
|
+
only". There are two ways to cache it:
|
|
11
|
+
|
|
12
|
+
* :func:`ghinsight.actions.fetch.fetch_billing_usage`, via the REST API, which
|
|
13
|
+
appears to admit organisation owners only (billing managers get a 404); or
|
|
14
|
+
* :func:`import_usage_csv`, from the usage report CSV that billing managers can
|
|
15
|
+
download from the organisation's billing pages (the *summarized* report
|
|
16
|
+
covers up to a year and has the per-repository breakdown needed here).
|
|
17
|
+
|
|
18
|
+
The report appears to list only usage that counted towards the plan quota:
|
|
19
|
+
public-repository usage on standard runners, which is free, is omitted. So the
|
|
20
|
+
billed minutes are used as they are, with no visibility filter, and they
|
|
21
|
+
reflect each repository's visibility at the time of use.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import csv
|
|
27
|
+
import json
|
|
28
|
+
from collections.abc import Iterable, Mapping
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
from datetime import timezone
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Any
|
|
33
|
+
|
|
34
|
+
import pandas as pd
|
|
35
|
+
|
|
36
|
+
from .config import DateRange
|
|
37
|
+
from .domain import BillingUsageItem, Visibility
|
|
38
|
+
from .fetch import CachePaths, billing_months
|
|
39
|
+
|
|
40
|
+
__all__ = ["BillingUsage", "BILLING_COLUMNS", "CSV_TO_API_FIELDS", "import_usage_csv"]
|
|
41
|
+
|
|
42
|
+
BILLING_COLUMNS: tuple[str, ...] = (
|
|
43
|
+
"date",
|
|
44
|
+
"month",
|
|
45
|
+
"repo",
|
|
46
|
+
"visibility",
|
|
47
|
+
"sku",
|
|
48
|
+
"runtime_os",
|
|
49
|
+
"minutes",
|
|
50
|
+
"gross_amount",
|
|
51
|
+
"discount_amount",
|
|
52
|
+
"net_amount",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _item_to_row(item: BillingUsageItem, visibility: Visibility) -> dict[str, Any]:
|
|
57
|
+
return {
|
|
58
|
+
"date": item.date,
|
|
59
|
+
"month": item.month,
|
|
60
|
+
"repo": item.repo,
|
|
61
|
+
"visibility": visibility.value,
|
|
62
|
+
"sku": item.sku,
|
|
63
|
+
"runtime_os": item.runtime_os.value,
|
|
64
|
+
"minutes": item.quantity,
|
|
65
|
+
"gross_amount": item.gross_amount,
|
|
66
|
+
"discount_amount": item.discount_amount,
|
|
67
|
+
"net_amount": item.net_amount,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _in_range(item: BillingUsageItem, date_range: DateRange) -> bool:
|
|
72
|
+
if item.date is None:
|
|
73
|
+
return False
|
|
74
|
+
date = item.date if item.date.tzinfo else item.date.replace(tzinfo=timezone.utc)
|
|
75
|
+
since, until = (
|
|
76
|
+
d if d.tzinfo else d.replace(tzinfo=timezone.utc)
|
|
77
|
+
for d in (date_range.since, date_range.until)
|
|
78
|
+
)
|
|
79
|
+
return since <= date < until
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True)
|
|
83
|
+
class BillingUsage:
|
|
84
|
+
"""Cached billing usage items for one account, plus the months they cover."""
|
|
85
|
+
|
|
86
|
+
org: str
|
|
87
|
+
items: tuple[BillingUsageItem, ...]
|
|
88
|
+
months: tuple[str, ...]
|
|
89
|
+
expected_months: tuple[str, ...] = ()
|
|
90
|
+
|
|
91
|
+
@classmethod
|
|
92
|
+
def from_cache(
|
|
93
|
+
cls, org: str, cache_dir: Path, date_range: DateRange | None = None
|
|
94
|
+
) -> "BillingUsage | None":
|
|
95
|
+
"""Load the cached report, or ``None`` if no month was cached.
|
|
96
|
+
|
|
97
|
+
With ``date_range``, only the months and items inside it are kept and
|
|
98
|
+
:attr:`expected_months` lists every month the range covers.
|
|
99
|
+
"""
|
|
100
|
+
paths = CachePaths(cache_dir, org)
|
|
101
|
+
expected = tuple(billing_months(date_range)) if date_range else ()
|
|
102
|
+
files = sorted(paths.billing_dir.glob("*.json")) if paths.billing_dir.exists() else []
|
|
103
|
+
if expected:
|
|
104
|
+
files = [f for f in files if f.stem in expected]
|
|
105
|
+
loaded = dict(_load_months(files))
|
|
106
|
+
if not loaded:
|
|
107
|
+
return None
|
|
108
|
+
items = tuple(item for month_items in loaded.values() for item in month_items)
|
|
109
|
+
if date_range is not None:
|
|
110
|
+
items = tuple(item for item in items if _in_range(item, date_range))
|
|
111
|
+
return cls(org=org, items=items, months=tuple(sorted(loaded)), expected_months=expected)
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def missing_months(self) -> tuple[str, ...]:
|
|
115
|
+
"""Months in the requested range with no cached report."""
|
|
116
|
+
return tuple(m for m in self.expected_months if m not in self.months)
|
|
117
|
+
|
|
118
|
+
def actions_minutes_frame(self, visibility: Mapping[str, Visibility]) -> pd.DataFrame:
|
|
119
|
+
"""One row per Actions-minutes usage item, tagged with repo visibility."""
|
|
120
|
+
rows = [
|
|
121
|
+
_item_to_row(item, visibility.get(item.repo, Visibility.UNKNOWN))
|
|
122
|
+
for item in self.items
|
|
123
|
+
if item.is_actions_minutes
|
|
124
|
+
]
|
|
125
|
+
if not rows:
|
|
126
|
+
return pd.DataFrame(columns=BILLING_COLUMNS)
|
|
127
|
+
return pd.DataFrame.from_records(rows, columns=BILLING_COLUMNS)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _load_months(files: Iterable[Path]) -> Iterable[tuple[str, list[BillingUsageItem]]]:
|
|
131
|
+
for path in files:
|
|
132
|
+
try:
|
|
133
|
+
payloads = json.loads(path.read_text(encoding="utf-8"))
|
|
134
|
+
except (OSError, json.JSONDecodeError):
|
|
135
|
+
continue
|
|
136
|
+
if not isinstance(payloads, list):
|
|
137
|
+
continue
|
|
138
|
+
items = [BillingUsageItem.from_payload(p) for p in payloads if isinstance(p, dict)]
|
|
139
|
+
yield path.stem, [item for item in items if item is not None]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# Usage-report CSV columns and the REST API ``usageItems`` fields they map to.
|
|
143
|
+
CSV_TO_API_FIELDS: Mapping[str, str] = {
|
|
144
|
+
"date": "date",
|
|
145
|
+
"product": "product",
|
|
146
|
+
"sku": "sku",
|
|
147
|
+
"quantity": "quantity",
|
|
148
|
+
"unit_type": "unitType",
|
|
149
|
+
"applied_cost_per_quantity": "pricePerUnit",
|
|
150
|
+
"gross_amount": "grossAmount",
|
|
151
|
+
"discount_amount": "discountAmount",
|
|
152
|
+
"net_amount": "netAmount",
|
|
153
|
+
"organization": "organizationName",
|
|
154
|
+
"repository": "repositoryName",
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _csv_row_to_item(row: Mapping[str, str]) -> dict[str, Any]:
|
|
159
|
+
item: dict[str, Any] = {
|
|
160
|
+
api: row[column] for column, api in CSV_TO_API_FIELDS.items() if column in row
|
|
161
|
+
}
|
|
162
|
+
for key in ("quantity", "pricePerUnit", "grossAmount", "discountAmount", "netAmount"):
|
|
163
|
+
if key in item:
|
|
164
|
+
try:
|
|
165
|
+
item[key] = float(item[key])
|
|
166
|
+
except ValueError:
|
|
167
|
+
item[key] = 0.0
|
|
168
|
+
return item
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def import_usage_csv(csv_path: Path, org: str, cache_dir: Path) -> list[str]:
|
|
172
|
+
"""Cache a billing usage report CSV as if it had been fetched from the API.
|
|
173
|
+
|
|
174
|
+
Rows are converted to the API's ``usageItems`` shape and written one file
|
|
175
|
+
per month under ``{cache_dir}/_billing/{org}/``, replacing any cached
|
|
176
|
+
month the CSV covers. Rows for other organisations are skipped. A month
|
|
177
|
+
the CSV only partly covers is cached as partial, so export whole months.
|
|
178
|
+
|
|
179
|
+
Returns the months written.
|
|
180
|
+
"""
|
|
181
|
+
by_month: dict[str, list[dict[str, Any]]] = {}
|
|
182
|
+
# ``utf-8-sig`` drops the byte-order mark GitHub puts before the header.
|
|
183
|
+
with csv_path.open(newline="", encoding="utf-8-sig") as handle:
|
|
184
|
+
for row in csv.DictReader(handle):
|
|
185
|
+
if row.get("organization", org).lower() != org.lower():
|
|
186
|
+
continue
|
|
187
|
+
month = (row.get("date") or "")[:7]
|
|
188
|
+
if len(month) == 7:
|
|
189
|
+
by_month.setdefault(month, []).append(_csv_row_to_item(row))
|
|
190
|
+
|
|
191
|
+
paths = CachePaths(cache_dir, org)
|
|
192
|
+
paths.billing_dir.mkdir(parents=True, exist_ok=True)
|
|
193
|
+
for month, items in by_month.items():
|
|
194
|
+
paths.billing_file(month).write_text(json.dumps(items), encoding="utf-8")
|
|
195
|
+
return sorted(by_month)
|