open-source-repository-sampler 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.
@@ -0,0 +1,20 @@
1
+ """Reproducible, seeded, stratified samples of open-source repositories, each pinned to the
2
+ commit that met the sampling criteria.
3
+
4
+ For Python experiments:
5
+
6
+ from open_source_repository_sampler import read_sample
7
+ for repo in read_sample("sample/open-source-repository-sample.csv"):
8
+ repo["url"], repo["commit"], repo["language"]
9
+
10
+ or let the sampler run the loop: each usable repository is cloned at its pinned commit (shallow,
11
+ or deep with every commit up to it and none after), handed to the experiment, then deleted:
12
+
13
+ from open_source_repository_sampler import for_each
14
+ def experiment(repo): # repo.path, repo.commit, repo.full_name, repo.row, ...
15
+ ...
16
+ for_each("sample/open-source-repository-sample.csv", experiment, deep=True,
17
+ run_log="results/run-log.jsonl")
18
+ """
19
+ from .checkout import Repository, clone_at, for_each # noqa: F401
20
+ from .files import read_sample # noqa: F401
@@ -0,0 +1,4 @@
1
+ # Written by setuptools-scm at build time. Do not edit or commit.
2
+ __version__ = version = '0.1.0'
3
+ __commit__ = 'g7502a15812cd704a5ab562e58b030f7279fd72a3'
4
+ __dirty__ = False
@@ -0,0 +1,265 @@
1
+ """Use a sample: get each usable repository exactly as it was sampled, and run an experiment on it.
2
+
3
+ The clone holds nothing after the pinned commit, so an experiment run years later sees exactly
4
+ the code, and the history, that was sampled:
5
+
6
+ * shallow only the pinned commit's tree (`git fetch --depth 1 <url> <commit>`);
7
+ * deep every commit up to and including the pinned commit, and nothing after it
8
+ (`git fetch <url> <commit>`: only that commit and its ancestors are transferred).
9
+
10
+ Both fetch by commit from the URL directly, with no tags and no remote configured, so no later
11
+ commit is in the clone and none can be fetched into it by accident.
12
+
13
+ A commit id is a hash over the commit's content and its parents, so the history up to a pinned
14
+ commit can never change: if a project later rewrites its history, the pinned commit is either
15
+ still served, with exactly the sampled history, or no longer served, and the clone fails loudly.
16
+ A deep clone is also checked against the `n_commits` measured at sampling (when the sample was
17
+ drawn with a history criterion), which catches an incomplete clone (a host that served grafted
18
+ or partial history).
19
+
20
+ `for_each` is the loop an experiment runs inside: for each usable repository it clones (shallow
21
+ or deep), hands the clone to the experiment, then deletes it, so disk use stays at one
22
+ repository at a time. The clone can always be made again from the sample.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import dataclasses
27
+ import datetime
28
+ import json
29
+ import os
30
+ import shutil
31
+ import subprocess
32
+ import tempfile
33
+ import time
34
+
35
+ from . import files
36
+ from .measure import network_git
37
+
38
+
39
+ def _git(args: list[str], cwd: str, timeout: float | None = None) -> subprocess.CompletedProcess:
40
+ return subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True,
41
+ timeout=timeout)
42
+
43
+
44
+ def dir_name(full_name: str) -> str:
45
+ return full_name.replace("/", "__")
46
+
47
+
48
+ def clone_at(url: str, commit: str, dest: str, deep: bool = False,
49
+ expected_commits: int | None = None, timeout: float | None = 3600) -> str | None:
50
+ """`url` into `dest`, detached at `commit`, holding nothing after it. Shallow: that commit
51
+ only. Deep: all of its history. With `expected_commits` (the sample's `n_commits`), a deep
52
+ clone whose history differs from the one sampled is refused. None on success, else an
53
+ error."""
54
+ shutil.rmtree(dest, ignore_errors=True)
55
+ os.makedirs(dest)
56
+ fetch = ["fetch", "--quiet", "--no-tags", url, commit]
57
+ if not deep:
58
+ fetch[1:1] = ["--depth", "1"]
59
+ for args in (["init", "--quiet"], fetch, ["checkout", "--quiet", "--detach", "FETCH_HEAD"]):
60
+ if args is fetch:
61
+ err = network_git(fetch, dest, timeout) # stall-aborted and retried
62
+ else:
63
+ r = _git(args, dest, timeout)
64
+ err = None if r.returncode == 0 else r.stderr.strip()[:160]
65
+ if err:
66
+ return f"git {args[0]} failed: {err}"
67
+ head = _git(["rev-parse", "HEAD"], dest).stdout.strip()
68
+ if head != commit:
69
+ return f"landed on {head[:12]}, not {commit[:12]}"
70
+ n_head = int(_git(["rev-list", "--count", "HEAD"], dest).stdout.strip() or 0)
71
+ n_all = int(_git(["rev-list", "--count", "--all"], dest).stdout.strip() or 0)
72
+ if n_all != n_head:
73
+ return f"clone holds {n_all - n_head} commits beyond {commit[:12]}"
74
+ if deep and expected_commits is not None and n_head != expected_commits:
75
+ return (f"history up to {commit[:12]} has {n_head} commits, the sample recorded "
76
+ f"{expected_commits}: the clone is incomplete")
77
+ return None
78
+
79
+
80
+ def _at(dest: str, commit: str) -> bool:
81
+ if not os.path.isdir(os.path.join(dest, ".git")):
82
+ return False
83
+ return _git(["rev-parse", "HEAD"], dest).stdout.strip() == commit
84
+
85
+
86
+ def _rows(sample: str, langs: list[str] | None) -> list[dict]:
87
+ return [r for r in files.read_sample(sample) if not langs or r["language"] in langs]
88
+
89
+
90
+ def checkout(sample: str, dest: str, langs: list[str] | None = None, deep: bool = False,
91
+ log=print) -> list[dict]:
92
+ """Clone the usable repositories of `sample` (optionally only `langs`) into `dest`, and
93
+ keep them. Returns one {full_name, path, commit, error} per repository."""
94
+ os.makedirs(dest, exist_ok=True)
95
+ out = []
96
+ rows = _rows(sample, langs)
97
+ for i, r in enumerate(rows, 1):
98
+ path = os.path.join(dest, dir_name(r["full_name"]))
99
+ if _at(path, r["commit"]):
100
+ err = None
101
+ log(f"[{i}/{len(rows)}] {r['full_name']}: already at {r['commit'][:12]}")
102
+ else:
103
+ log(f"[{i}/{len(rows)}] {r['full_name']} @ {r['commit'][:12]} "
104
+ f"({'deep' if deep else 'shallow'}) ...")
105
+ err = clone_at(r["url"], r["commit"], path, deep, r.get("n_commits") or None)
106
+ if err:
107
+ log(f" ERROR {err}")
108
+ out.append({"full_name": r["full_name"], "path": path, "commit": r["commit"],
109
+ "error": err})
110
+ bad = [o for o in out if o["error"]]
111
+ log(f"{len(out) - len(bad)}/{len(out)} repositories at their pinned commit in {dest}")
112
+ return out
113
+
114
+
115
+ @dataclasses.dataclass
116
+ class Repository:
117
+ """One usable repository of a sample, cloned at its pinned commit, as an experiment gets it."""
118
+ full_name: str
119
+ url: str
120
+ commit: str
121
+ language: str
122
+ path: str # the clone; deleted after the experiment returns (unless kept)
123
+ deep: bool # whether `path` holds the history up to `commit`
124
+ row: dict # the whole CSV row: stars, tier, criteria measures, ...
125
+
126
+
127
+ def _done(log_path: str | None) -> set:
128
+ """(full_name, commit) pairs the run log records as done."""
129
+ done = set()
130
+ if log_path and os.path.exists(log_path):
131
+ with open(log_path) as fh:
132
+ for line in fh:
133
+ if line.strip():
134
+ rec = json.loads(line)
135
+ if rec.get("status") == "done":
136
+ done.add((rec["full_name"], rec["commit"]))
137
+ return done
138
+
139
+
140
+ def for_each(sample: str, experiment, deep: bool = False, langs: list[str] | None = None,
141
+ work_dir: str | None = None, keep: bool = False, run_log: str | None = None,
142
+ fail_fast: bool = False, log=print) -> list[dict]:
143
+ """Run `experiment(repository)` on every usable repository of `sample`, one at a time: clone
144
+ it at its pinned commit (deep or shallow), call the experiment, delete the clone.
145
+
146
+ A failure (clone, or the experiment raising) is recorded and the loop moves on, unless
147
+ `fail_fast`. With `run_log`, each outcome is appended to that JSON Lines file and a re-run
148
+ skips the repositories it records as done, so an interrupted run resumes.
149
+ Returns one {full_name, commit, status, error, result, seconds} per repository."""
150
+ rows = _rows(sample, langs)
151
+ done = _done(run_log)
152
+ own_work = work_dir is None
153
+ work = tempfile.mkdtemp(prefix="osrs-run-") if own_work else work_dir
154
+ os.makedirs(work, exist_ok=True)
155
+ out = []
156
+ try:
157
+ for i, r in enumerate(rows, 1):
158
+ tag = f"[{i}/{len(rows)}] {r['full_name']} @ {r['commit'][:12]}"
159
+ if (r["full_name"], r["commit"]) in done:
160
+ log(f"{tag}: done in an earlier run, skipped")
161
+ out.append({"full_name": r["full_name"], "commit": r["commit"],
162
+ "status": "skipped", "error": None, "result": None, "seconds": 0})
163
+ continue
164
+ path = os.path.join(work, dir_name(r["full_name"]))
165
+ t0 = time.time()
166
+ log(f"{tag}: cloning ({'deep' if deep else 'shallow'}) ...")
167
+ err = clone_at(r["url"], r["commit"], path, deep, r.get("n_commits") or None)
168
+ result = None
169
+ if not err:
170
+ log(f"{tag}: running experiment ...")
171
+ try:
172
+ result = experiment(Repository(r["full_name"], r["url"], r["commit"],
173
+ r["language"], path, deep, r))
174
+ except Exception as e:
175
+ err = f"experiment raised {type(e).__name__}: {e}"
176
+ if not keep:
177
+ shutil.rmtree(path, ignore_errors=True)
178
+ rec = {"full_name": r["full_name"], "commit": r["commit"],
179
+ "status": "failed" if err else "done", "error": err,
180
+ "seconds": round(time.time() - t0, 1)}
181
+ log(f"{tag}: {rec['status']}" + (f" ({err})" if err else "")
182
+ + f" in {rec['seconds']}s")
183
+ if run_log:
184
+ with open(run_log, "a") as fh:
185
+ fh.write(json.dumps({**rec, "finished_utc": datetime.datetime.now(
186
+ datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")}) + "\n")
187
+ out.append({**rec, "result": result})
188
+ if err and fail_fast:
189
+ break
190
+ finally:
191
+ if own_work and not keep:
192
+ shutil.rmtree(work, ignore_errors=True)
193
+ n = {s: sum(1 for o in out if o["status"] == s) for s in ("done", "skipped", "failed")}
194
+ log(f"{n['done']} done, {n['skipped']} skipped (already done), {n['failed']} failed "
195
+ f"of {len(rows)} repositories")
196
+ return out
197
+
198
+
199
+ # Placeholders a `run` command may use in its arguments, and the environment variables every
200
+ # run command gets, for experiments not written in Python.
201
+ def _fields(repo: Repository) -> dict:
202
+ return {"path": repo.path, "url": repo.url, "commit": repo.commit,
203
+ "full_name": repo.full_name, "language": repo.language,
204
+ "deep": "true" if repo.deep else "false"}
205
+
206
+
207
+ def command_experiment(command: list[str]):
208
+ """An experiment that runs `command` once per repository: `{path}`, `{url}`, `{commit}`,
209
+ `{full_name}`, `{language}` and `{deep}` in its arguments are replaced by the repository's
210
+ values, which are also in the environment as SAMPLED_REPOSITORY_PATH, ..._URL, ..._COMMIT,
211
+ ..._FULL_NAME, ..._LANGUAGE and ..._DEEP. A non-zero exit is a failure."""
212
+ def run(repo: Repository) -> int:
213
+ fields = _fields(repo)
214
+ args = []
215
+ for a in command:
216
+ for k, v in fields.items():
217
+ a = a.replace("{" + k + "}", v)
218
+ args.append(a)
219
+ env = {**os.environ, **{f"SAMPLED_REPOSITORY_{k.upper()}": v for k, v in fields.items()}}
220
+ code = subprocess.run(args, env=env).returncode
221
+ if code != 0:
222
+ raise RuntimeError(f"command exited {code}")
223
+ return code
224
+ return run
225
+
226
+
227
+ def verify(sample: str, fetch: bool = False, log=print) -> list[str]:
228
+ """Problems with a sample; empty when it is intact. Checks the CSV against the hash in its
229
+ derivation, and with `fetch`, that every pinned commit can still be fetched."""
230
+ if os.path.isdir(sample):
231
+ sample = os.path.join(sample, files.names()[0])
232
+ deriv = sample[:-len(".csv")] + ".derivation.json"
233
+ problems = []
234
+ if not sample.endswith(".csv") or not os.path.exists(deriv):
235
+ return [f"no derivation file beside {sample} (expected {deriv})"]
236
+ with open(deriv) as fh:
237
+ d = json.load(fh)
238
+ got = files.sha256(sample)
239
+ if got != d.get("csv_sha256"):
240
+ problems.append(f"{os.path.basename(sample)} sha256 {got} does not match the "
241
+ f"derivation's {d.get('csv_sha256')}: the CSV was changed after sampling")
242
+ else:
243
+ log(f"OK {os.path.basename(sample)} matches its derivation (sha256 {got[:16]}...)")
244
+ s = d.get("sampler", {})
245
+ log(f" drawn by {s.get('name')} {s.get('version')} @ {s.get('commit')}"
246
+ + (" (DIRTY)" if s.get("dirty") else ""))
247
+ rows = files.read_sample(sample)
248
+ commits = {r["sampler_commit"] for r in rows}
249
+ if commits - {s.get("commit") or ""}:
250
+ problems.append(f"rows name sampler commits {sorted(commits)}, the derivation "
251
+ f"{s.get('commit')}")
252
+ if fetch:
253
+ with tempfile.TemporaryDirectory(prefix="osrs-verify-") as tmp:
254
+ _git(["init", "--quiet", "--bare"], tmp)
255
+ for r in rows:
256
+ # Stall-aborted and retried, so a transient outage is not reported as a lost
257
+ # commit.
258
+ err = network_git(["fetch", "--depth", "1", "--quiet", r["url"], r["commit"]],
259
+ tmp, 600)
260
+ if err:
261
+ problems.append(f"{r['full_name']}: commit {r['commit'][:12]} can no "
262
+ f"longer be fetched ({err})")
263
+ else:
264
+ log(f"OK {r['full_name']} @ {r['commit'][:12]}")
265
+ return problems
@@ -0,0 +1,136 @@
1
+ """open-source-repository-sampler: draw, use and verify reproducible samples of repositories.
2
+
3
+ open-source-repository-sampler sample --langs java --target 30 --seed 1 --out-dir sample
4
+ open-source-repository-sampler checkout sample --dest repos [--deep]
5
+ open-source-repository-sampler run sample [--deep] -- my-experiment {path} -o out/{full_name}
6
+ open-source-repository-sampler verify sample --fetch
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import sys
12
+
13
+ from . import checkout, frame, provenance, sample
14
+
15
+
16
+ def _list(s: str) -> list[str]:
17
+ return [x.strip() for x in s.split(",") if x.strip()]
18
+
19
+
20
+ def _sample_args(sp: argparse.ArgumentParser) -> None:
21
+ d = sample.DEFAULTS
22
+ g = sp.add_argument_group("what to sample")
23
+ g.add_argument("--langs", type=_list, default=d["langs"],
24
+ help=f"comma list of languages (default all: {','.join(frame.LANGS)})")
25
+ g.add_argument("--tiers", type=_list, default=d["tiers"],
26
+ help="comma list of popularity tiers: "
27
+ + ", ".join(f"{k} ({v})" for k, v in frame.TIERS.items())
28
+ + " (default all)")
29
+ g.add_argument("--target", type=int, default=d["target"],
30
+ help=f"usable repositories wanted per language (default {d['target']})")
31
+ g.add_argument("--seed", type=int, default=d["seed"],
32
+ help=f"the one value that fixes the draw; fix it before the first draw and "
33
+ f"keep the first sample (default {d['seed']})")
34
+ g.add_argument("--exclude-owners", type=_list, default=d["exclude_owners"],
35
+ help="comma list of GitHub owners never sampled, e.g. your own (a "
36
+ "conflict-of-interest guard; default none)")
37
+ c = sp.add_argument_group("criteria a repository must meet to be usable")
38
+ for p, col, desc in sample.CRITERIA:
39
+ typ = float if isinstance(d[p], float) else int
40
+ c.add_argument("--" + p.replace("_", "-"), type=typ, default=d[p],
41
+ help=f"minimum {desc} (default {d[p]})")
42
+ r = sp.add_argument_group("output and run control")
43
+ r.add_argument("--out-dir", default=".",
44
+ help="directory for the sample files (default: the current directory)")
45
+ r.add_argument("--label", default=None,
46
+ help="optional label in the file names, to keep several samples side by side")
47
+ r.add_argument("--max-clones", type=int, default=d["max_clones"],
48
+ help=f"safety stop on total candidates drawn (default {d['max_clones']})")
49
+ r.add_argument("--pause", type=float, default=d["pause"],
50
+ help=f"seconds between GitHub searches (default {d['pause']})")
51
+ r.add_argument("--allow-dirty", action="store_true",
52
+ help="run even though the sampler has uncommitted changes (recorded)")
53
+ r.add_argument("--overwrite", action="store_true",
54
+ help="replace existing sample files in --out-dir")
55
+
56
+
57
+ def main(argv: list[str] | None = None) -> int:
58
+ argv = sys.argv[1:] if argv is None else argv
59
+ command = []
60
+ if "--" in argv: # `run ... -- <experiment command>`
61
+ i = argv.index("--")
62
+ argv, command = argv[:i], argv[i + 1:]
63
+ ap = argparse.ArgumentParser(prog=provenance.NAME, description=__doc__.splitlines()[0])
64
+ ap.add_argument("--version", action="store_true", help="print the sampler version and commit")
65
+ sub = ap.add_subparsers(dest="cmd")
66
+
67
+ sp = sub.add_parser("sample", help="draw a sample (writes the CSV and its derivation)")
68
+ _sample_args(sp)
69
+
70
+ cp = sub.add_parser("checkout", help="clone a sample's usable repositories at their commits")
71
+ cp.add_argument("sample", help="the sample CSV, or the directory holding it")
72
+ cp.add_argument("--dest", default="repos", help="directory to clone into (default repos)")
73
+ cp.add_argument("--langs", type=_list, default=None, help="only these detected languages")
74
+ cp.add_argument("--deep", action="store_true",
75
+ help="every commit up to each pinned commit (default: that commit only)")
76
+
77
+ rp = sub.add_parser(
78
+ "run", help="run an experiment command on each usable repository, one clone at a time",
79
+ usage=f"{provenance.NAME} run SAMPLE [options] -- COMMAND [ARGS with {{path}} ...]",
80
+ description="Clone each usable repository at its pinned commit, run the command given "
81
+ "after `--` on it, then delete the clone. In the command, {path}, {url}, "
82
+ "{commit}, {full_name}, {language} and {deep} are replaced by the "
83
+ "repository's values, which are also in the environment as "
84
+ "SAMPLED_REPOSITORY_PATH, _URL, _COMMIT, _FULL_NAME, _LANGUAGE and _DEEP.")
85
+ rp.add_argument("sample", help="the sample CSV, or the directory holding it")
86
+ rp.add_argument("--deep", action="store_true",
87
+ help="every commit up to each pinned commit (default: that commit only)")
88
+ rp.add_argument("--langs", type=_list, default=None, help="only these detected languages")
89
+ rp.add_argument("--work-dir", default=None,
90
+ help="where clones are made (default: a temporary directory)")
91
+ rp.add_argument("--keep", action="store_true", help="keep each clone after its run")
92
+ rp.add_argument("--run-log", default=None,
93
+ help="JSON Lines file of outcomes; a re-run skips repositories it records "
94
+ "as done, so an interrupted run resumes")
95
+ rp.add_argument("--fail-fast", action="store_true", help="stop at the first failure")
96
+
97
+ vp = sub.add_parser("verify", help="check a sample's CSV against its derivation")
98
+ vp.add_argument("sample", help="the sample CSV, or the directory holding it")
99
+ vp.add_argument("--fetch", action="store_true",
100
+ help="also check every pinned commit can still be fetched")
101
+
102
+ args = ap.parse_args(argv)
103
+ if args.version:
104
+ s = provenance.sampler()
105
+ print(f"{s['name']} {s['version']} @ {s['commit']}" + (" (dirty)" if s["dirty"] else ""))
106
+ return 0
107
+ if args.cmd == "sample":
108
+ params = {k: getattr(args, k) for k in sample.DEFAULTS}
109
+ try:
110
+ sample.run(params, args.out_dir, args.label, argv, args.allow_dirty, args.overwrite)
111
+ except (RuntimeError, ValueError, FileExistsError) as e:
112
+ print(f"ERROR: {e}", file=sys.stderr)
113
+ return 2
114
+ return 0
115
+ if args.cmd == "checkout":
116
+ res = checkout.checkout(args.sample, args.dest, args.langs, args.deep)
117
+ return 1 if any(r["error"] for r in res) else 0
118
+ if args.cmd == "run":
119
+ if not command:
120
+ print("ERROR: give the experiment command after `--`", file=sys.stderr)
121
+ return 2
122
+ res = checkout.for_each(args.sample, checkout.command_experiment(command), args.deep,
123
+ args.langs, args.work_dir, args.keep, args.run_log,
124
+ args.fail_fast)
125
+ return 1 if any(r["status"] == "failed" for r in res) else 0
126
+ if args.cmd == "verify":
127
+ problems = checkout.verify(args.sample, args.fetch)
128
+ for p in problems:
129
+ print(f"PROBLEM {p}", file=sys.stderr)
130
+ return 1 if problems else 0
131
+ ap.print_help()
132
+ return 2
133
+
134
+
135
+ if __name__ == "__main__":
136
+ sys.exit(main())
@@ -0,0 +1,106 @@
1
+ """The two sample files, their fixed names, and reading a sample back.
2
+
3
+ The names are fixed so a sample can be dropped into a paper's ancillary data unchanged and be
4
+ recognised on sight:
5
+
6
+ open-source-repository-sample.csv one row per candidate drawn
7
+ open-source-repository-sample.derivation.json how the sample was derived, and how to redo it
8
+
9
+ An optional label is inserted before the suffix (`open-source-repository-sample.java-v2.csv`)
10
+ so several samples can sit side by side without breaking the convention.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import csv
15
+ import hashlib
16
+ import os
17
+ import re
18
+
19
+ STEM = "open-source-repository-sample"
20
+
21
+ # Column order of the CSV. Every row of every sample has all of these.
22
+ COLUMNS = [
23
+ "draw", # 1-based order in which the candidate was drawn
24
+ "status", # usable | excluded (by the criteria) | error (could not inspect)
25
+ "reason", # why excluded, or the error (empty when usable)
26
+ "url", # clone URL
27
+ "full_name", # owner/name
28
+ "commit", # the pinned commit: the exact code checked against the criteria
29
+ "commit_date", # committer date of that commit (ISO 8601)
30
+ "language", # language detected from the project's own source
31
+ "language_share", # that language's fraction of the non-header source files
32
+ "github_language", # GitHub's label at sampling time
33
+ "mirror", # True when GitHub lists it as a mirror of a repository elsewhere
34
+ "stratum_language", # the stratum the candidate was drawn from
35
+ "tier",
36
+ "star_band", # the frame band (star range, maybe a creation-date range) it was in
37
+ "stars", # at sampling time
38
+ "rank", # rank by stars within its band's search results
39
+ "n_source_files",
40
+ "n_functions",
41
+ "n_files_with_functions",
42
+ "nloc",
43
+ "n_unparsed_files", # source files lizard could not parse (no functions counted)
44
+ # The history columns are measured only when a history criterion (--min-commits,
45
+ # --min-contributors) is set, which is when the full history is cloned; else empty.
46
+ "n_commits", # reachable from the pinned commit
47
+ "n_contributors", # distinct author emails reachable from the pinned commit
48
+ "first_commit_date",
49
+ "sampler_version",
50
+ "sampler_commit", # the commit of the sampler that produced this row
51
+ ]
52
+ INT_COLUMNS = {"draw", "stars", "rank", "n_source_files", "n_functions",
53
+ "n_files_with_functions", "nloc", "n_unparsed_files", "n_commits",
54
+ "n_contributors"}
55
+ FLOAT_COLUMNS = {"language_share"}
56
+
57
+ _LABEL = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
58
+
59
+
60
+ def check_label(label: str | None) -> None:
61
+ if label is not None and (not _LABEL.match(label) or label.endswith(".derivation")):
62
+ raise ValueError(f"label {label!r}: use letters, digits, '.', '_' or '-' only")
63
+
64
+
65
+ def names(label: str | None = None) -> tuple[str, str, str]:
66
+ """(csv, derivation, progress) file names for a sample."""
67
+ check_label(label)
68
+ stem = STEM if not label else f"{STEM}.{label}"
69
+ return f"{stem}.csv", f"{stem}.derivation.json", f".{stem}.progress.jsonl"
70
+
71
+
72
+ def sha256(path: str) -> str:
73
+ h = hashlib.sha256()
74
+ with open(path, "rb") as fh:
75
+ for chunk in iter(lambda: fh.read(1 << 16), b""):
76
+ h.update(chunk)
77
+ return h.hexdigest()
78
+
79
+
80
+ def write_csv(path: str, rows: list[dict]) -> None:
81
+ with open(path, "w", newline="") as fh:
82
+ w = csv.DictWriter(fh, fieldnames=COLUMNS, extrasaction="ignore", lineterminator="\n")
83
+ w.writeheader()
84
+ for r in rows:
85
+ w.writerow({c: "" if r.get(c) is None else r.get(c) for c in COLUMNS})
86
+
87
+
88
+ def read_sample(path: str, usable_only: bool = True) -> list[dict]:
89
+ """The rows of a sample CSV (only the usable ones by default), numbers typed. `path` is the
90
+ CSV itself or the directory holding an unlabelled sample."""
91
+ if os.path.isdir(path):
92
+ path = os.path.join(path, names()[0])
93
+ with open(path, newline="") as fh:
94
+ rows = list(csv.DictReader(fh))
95
+ out = []
96
+ for r in rows:
97
+ if usable_only and r.get("status") != "usable":
98
+ continue
99
+ for c in INT_COLUMNS:
100
+ if r.get(c) not in (None, ""):
101
+ r[c] = int(r[c])
102
+ for c in FLOAT_COLUMNS:
103
+ if r.get(c) not in (None, ""):
104
+ r[c] = float(r[c])
105
+ out.append(r)
106
+ return out