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.
- open_source_repository_sampler/__init__.py +20 -0
- open_source_repository_sampler/_version.py +4 -0
- open_source_repository_sampler/checkout.py +265 -0
- open_source_repository_sampler/cli.py +136 -0
- open_source_repository_sampler/files.py +106 -0
- open_source_repository_sampler/frame.py +203 -0
- open_source_repository_sampler/measure.py +265 -0
- open_source_repository_sampler/provenance.py +129 -0
- open_source_repository_sampler/sample.py +446 -0
- open_source_repository_sampler-0.1.0.dist-info/METADATA +264 -0
- open_source_repository_sampler-0.1.0.dist-info/RECORD +15 -0
- open_source_repository_sampler-0.1.0.dist-info/WHEEL +5 -0
- open_source_repository_sampler-0.1.0.dist-info/entry_points.txt +2 -0
- open_source_repository_sampler-0.1.0.dist-info/licenses/LICENSE +201 -0
- open_source_repository_sampler-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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,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
|