graphban-cli 0.7.0__tar.gz → 0.7.1__tar.gz
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.
- {graphban_cli-0.7.0 → graphban_cli-0.7.1}/PKG-INFO +1 -1
- {graphban_cli-0.7.0 → graphban_cli-0.7.1}/pyproject.toml +1 -1
- {graphban_cli-0.7.0 → graphban_cli-0.7.1}/src/gban/cli.py +25 -1
- {graphban_cli-0.7.0 → graphban_cli-0.7.1}/src/gban/client.py +4 -0
- {graphban_cli-0.7.0 → graphban_cli-0.7.1}/src/gban/stackmap.py +109 -16
- {graphban_cli-0.7.0 → graphban_cli-0.7.1}/tests/test_stackmap.py +181 -0
- {graphban_cli-0.7.0 → graphban_cli-0.7.1}/.gitignore +0 -0
- {graphban_cli-0.7.0 → graphban_cli-0.7.1}/LICENSE +0 -0
- {graphban_cli-0.7.0 → graphban_cli-0.7.1}/README.md +0 -0
- {graphban_cli-0.7.0 → graphban_cli-0.7.1}/src/gban/__init__.py +0 -0
- {graphban_cli-0.7.0 → graphban_cli-0.7.1}/src/gban/config.py +0 -0
- {graphban_cli-0.7.0 → graphban_cli-0.7.1}/src/gban/data/stack_sdks.toml +0 -0
- {graphban_cli-0.7.0 → graphban_cli-0.7.1}/src/gban/doctor.py +0 -0
- {graphban_cli-0.7.0 → graphban_cli-0.7.1}/src/gban/gitignore.py +0 -0
- {graphban_cli-0.7.0 → graphban_cli-0.7.1}/src/gban/setup.py +0 -0
- {graphban_cli-0.7.0 → graphban_cli-0.7.1}/src/gban/skills/graphban-delegation/SKILL.md +0 -0
- {graphban_cli-0.7.0 → graphban_cli-0.7.1}/src/gban/skills/watch-wave/SKILL.md +0 -0
- {graphban_cli-0.7.0 → graphban_cli-0.7.1}/src/gban/swamp.py +0 -0
- {graphban_cli-0.7.0 → graphban_cli-0.7.1}/tests/test_acts.py +0 -0
- {graphban_cli-0.7.0 → graphban_cli-0.7.1}/tests/test_doctor.py +0 -0
- {graphban_cli-0.7.0 → graphban_cli-0.7.1}/tests/test_login_projects.py +0 -0
- {graphban_cli-0.7.0 → graphban_cli-0.7.1}/tests/test_packaging.py +0 -0
- {graphban_cli-0.7.0 → graphban_cli-0.7.1}/tests/test_session.py +0 -0
- {graphban_cli-0.7.0 → graphban_cli-0.7.1}/tests/test_setup.py +0 -0
- {graphban_cli-0.7.0 → graphban_cli-0.7.1}/tests/test_swamp.py +0 -0
|
@@ -442,6 +442,7 @@ def cmd_stack(args) -> int:
|
|
|
442
442
|
text = stackmap.canonical(record)
|
|
443
443
|
if args.dry_run:
|
|
444
444
|
sys.stdout.write(text)
|
|
445
|
+
print(f"{PROG}: dry run, nothing posted", file=sys.stderr)
|
|
445
446
|
return 0
|
|
446
447
|
if not project:
|
|
447
448
|
print(f"{PROG}: `{PROG} stack map` needs a project to post. Pass --project, set "
|
|
@@ -463,8 +464,31 @@ def cmd_stack(args) -> int:
|
|
|
463
464
|
f"${config.API_KEY_ENV}, or run `{PROG} setup`. The login session is not a "
|
|
464
465
|
f"key and is not sent.", file=sys.stderr)
|
|
465
466
|
return EXIT_REFUSED
|
|
466
|
-
|
|
467
|
+
# The record goes to stdout only after the server took it: a refused post must not
|
|
468
|
+
# leave behind something that reads like a record that was sent (GRPH-1103).
|
|
469
|
+
try:
|
|
470
|
+
receipt = stackmap.post_record(url, key, record)
|
|
471
|
+
except Refused as exc:
|
|
472
|
+
try:
|
|
473
|
+
reasons = json.loads(exc.detail)
|
|
474
|
+
except ValueError:
|
|
475
|
+
reasons = None
|
|
476
|
+
if not isinstance(reasons, list):
|
|
477
|
+
raise
|
|
478
|
+
print(f"{PROG}: the server refused the app map (HTTP {exc.status}):", file=sys.stderr)
|
|
479
|
+
for reason in reasons:
|
|
480
|
+
if isinstance(reason, dict):
|
|
481
|
+
print(f" {reason.get('path', '?')}: {reason.get('reason', '')}",
|
|
482
|
+
file=sys.stderr)
|
|
483
|
+
else:
|
|
484
|
+
print(f" {reason}", file=sys.stderr)
|
|
485
|
+
return EXIT_REFUSED
|
|
467
486
|
sys.stdout.write(text)
|
|
487
|
+
receipt = receipt if isinstance(receipt, dict) else {}
|
|
488
|
+
sha = str(receipt.get("mapped_sha") or record.get("mapped_sha") or "")[:7]
|
|
489
|
+
said = [f"{k} {receipt[k]}" for k in ("mapped_at", "posted_by") if receipt.get(k)]
|
|
490
|
+
print(f"{PROG}: posted {receipt.get('project_id') or project} app map at {sha} to {url}"
|
|
491
|
+
+ (f" ({', '.join(said)})" if said else ""), file=sys.stderr)
|
|
468
492
|
return 0
|
|
469
493
|
|
|
470
494
|
|
|
@@ -115,6 +115,10 @@ class Client:
|
|
|
115
115
|
detail = payload.get("detail") or payload.get("message") or f"HTTP {status}"
|
|
116
116
|
if isinstance(detail, dict):
|
|
117
117
|
detail, hint = (detail.get("message") or str(detail)), detail.get("hint", "")
|
|
118
|
+
elif isinstance(detail, list):
|
|
119
|
+
# A list of reasons (e.g. `{path, reason}` from a refused app map) stays JSON,
|
|
120
|
+
# so a caller can read it back rather than parse a Python repr.
|
|
121
|
+
detail, hint = json.dumps(detail), payload.get("hint", "")
|
|
118
122
|
else:
|
|
119
123
|
hint = payload.get("hint", "")
|
|
120
124
|
raise Refused(status, str(detail), str(hint))
|
|
@@ -853,6 +853,13 @@ class _Builder:
|
|
|
853
853
|
self.by_dir: dict[str, str] = {}
|
|
854
854
|
self.kind_of: dict[str, str] = {}
|
|
855
855
|
self.warnings: list[str] = []
|
|
856
|
+
# Which compose services name an env variable, and which read a template
|
|
857
|
+
# through `env_file:`. Every service counts, not only the ones that build a
|
|
858
|
+
# component: a repo-root template is attributed from these.
|
|
859
|
+
self.env_users: dict[str, set[str]] = {}
|
|
860
|
+
self.env_file_users: dict[str, set[str]] = {}
|
|
861
|
+
# Names each `.env.example` declares, keyed by its directory.
|
|
862
|
+
self.template_names: dict[str, set[str]] = {}
|
|
856
863
|
|
|
857
864
|
def _ids(self) -> set[str]:
|
|
858
865
|
return set(self.components) | set(self.infra) | set(self.external) | set(self.unresolved)
|
|
@@ -1109,6 +1116,32 @@ def _extends(service: Marked) -> tuple[str, str, int] | None:
|
|
|
1109
1116
|
return None
|
|
1110
1117
|
|
|
1111
1118
|
|
|
1119
|
+
def _env_files(service: Marked, compose: Path, repo: Path) -> list[str]:
|
|
1120
|
+
"""Repo-relative paths a service's `env_file:` names."""
|
|
1121
|
+
if not isinstance(service.value, dict):
|
|
1122
|
+
return []
|
|
1123
|
+
node = service.value.get("env_file")
|
|
1124
|
+
if not isinstance(node, Marked):
|
|
1125
|
+
return []
|
|
1126
|
+
items = node.value if isinstance(node.value, list) else [node]
|
|
1127
|
+
out: list[str] = []
|
|
1128
|
+
for item in items:
|
|
1129
|
+
if not isinstance(item, Marked):
|
|
1130
|
+
continue
|
|
1131
|
+
raw = item.value
|
|
1132
|
+
if isinstance(raw, dict):
|
|
1133
|
+
# The long form: `- path: ./x.env`.
|
|
1134
|
+
raw = raw.get("path").value if isinstance(raw.get("path"), Marked) else None
|
|
1135
|
+
if not isinstance(raw, str) or not raw.strip():
|
|
1136
|
+
continue
|
|
1137
|
+
try:
|
|
1138
|
+
target = (compose.parent / raw.strip().strip("'\"")).resolve()
|
|
1139
|
+
out.append(target.relative_to(repo.resolve()).as_posix())
|
|
1140
|
+
except ValueError:
|
|
1141
|
+
continue
|
|
1142
|
+
return out
|
|
1143
|
+
|
|
1144
|
+
|
|
1112
1145
|
def _walk_compose(builder: _Builder, path: Path) -> None:
|
|
1113
1146
|
text = _read_text(path)
|
|
1114
1147
|
if text is None:
|
|
@@ -1185,7 +1218,13 @@ def _walk_compose(builder: _Builder, path: Path) -> None:
|
|
|
1185
1218
|
builder.add_edge(src, target, protocol, "depends_on", prov)
|
|
1186
1219
|
env_node = service.value.get("environment")
|
|
1187
1220
|
for ename, raw, line in _as_env(env_node if isinstance(env_node, Marked) else None):
|
|
1221
|
+
for mentioned in [ename] + _INTERP.findall(raw):
|
|
1222
|
+
mentioned = re.split(r"[:?+-]", mentioned, maxsplit=1)[0].strip()
|
|
1223
|
+
if mentioned:
|
|
1224
|
+
builder.env_users.setdefault(mentioned, set()).add(src)
|
|
1188
1225
|
builder.apply_env_name(ename, raw, line, file, [src], "compose.env")
|
|
1226
|
+
for template in _env_files(service, path, builder.repo):
|
|
1227
|
+
builder.env_file_users.setdefault(template, set()).add(src)
|
|
1189
1228
|
|
|
1190
1229
|
|
|
1191
1230
|
def _walk_dockerfile(builder: _Builder, path: Path) -> None:
|
|
@@ -1247,6 +1286,61 @@ def _walk_manifest(builder: _Builder, path: Path) -> None:
|
|
|
1247
1286
|
str(row.get("label") or ""), prov)
|
|
1248
1287
|
|
|
1249
1288
|
|
|
1289
|
+
def _template_entries(text: str) -> list[tuple[str, str, int]]:
|
|
1290
|
+
"""`(name, raw_value, line)` for each assignment in an env template."""
|
|
1291
|
+
entries: list[tuple[str, str, int]] = []
|
|
1292
|
+
for lineno, raw in enumerate(text.splitlines(), 1):
|
|
1293
|
+
line = raw.strip()
|
|
1294
|
+
if not line or line.startswith("#"):
|
|
1295
|
+
continue
|
|
1296
|
+
if line.startswith("export "):
|
|
1297
|
+
line = line[len("export "):].strip()
|
|
1298
|
+
if "=" not in line:
|
|
1299
|
+
continue
|
|
1300
|
+
name, _, raw_value = line.partition("=")
|
|
1301
|
+
name = name.strip()
|
|
1302
|
+
if len(raw_value) >= 2 and raw_value[0] == raw_value[-1] and raw_value[0] in ("'", '"'):
|
|
1303
|
+
raw_value = raw_value[1:-1]
|
|
1304
|
+
entries.append((name, raw_value, lineno))
|
|
1305
|
+
return entries
|
|
1306
|
+
|
|
1307
|
+
|
|
1308
|
+
def _note_template(builder: _Builder, path: Path) -> None:
|
|
1309
|
+
text = _read_text(path)
|
|
1310
|
+
if text is None:
|
|
1311
|
+
return
|
|
1312
|
+
names = {name for name, _raw, _line in _template_entries(text)}
|
|
1313
|
+
builder.template_names.setdefault(_rel(path.parent, builder.repo), set()).update(names)
|
|
1314
|
+
|
|
1315
|
+
|
|
1316
|
+
def _root_consumers(builder: _Builder, file: str,
|
|
1317
|
+
entries: list[tuple[str, str, int]]) -> dict[str, list[str]] | None:
|
|
1318
|
+
"""Per name, the compose services a repo-root template's variable belongs to.
|
|
1319
|
+
|
|
1320
|
+
Any compose service counts, a plain `image:` one too. None only when no
|
|
1321
|
+
service names any of the template's variables or reads it: the caller then
|
|
1322
|
+
falls back to every component and says so. A name compose does not mention goes to
|
|
1323
|
+
the components whose own directory template declares it, else to the
|
|
1324
|
+
component at the repo root, else to nobody. The node is still drawn.
|
|
1325
|
+
"""
|
|
1326
|
+
readers = builder.env_file_users.get(file, set())
|
|
1327
|
+
names = [name for name, _raw, _line in entries if _interesting_env(name)]
|
|
1328
|
+
if not readers and not any(name in builder.env_users for name in names):
|
|
1329
|
+
return None
|
|
1330
|
+
root = builder.by_dir.get("")
|
|
1331
|
+
out: dict[str, list[str]] = {}
|
|
1332
|
+
for name in names:
|
|
1333
|
+
users = readers | builder.env_users.get(name, set())
|
|
1334
|
+
if not users:
|
|
1335
|
+
users = {builder.by_dir[folder]
|
|
1336
|
+
for folder, declared in builder.template_names.items()
|
|
1337
|
+
if folder and name in declared and folder in builder.by_dir}
|
|
1338
|
+
if not users and root:
|
|
1339
|
+
users = {root}
|
|
1340
|
+
out[name] = sorted(users)
|
|
1341
|
+
return out
|
|
1342
|
+
|
|
1343
|
+
|
|
1250
1344
|
def _walk_env_template(builder: _Builder, path: Path) -> None:
|
|
1251
1345
|
text = _read_text(path)
|
|
1252
1346
|
if text is None:
|
|
@@ -1254,8 +1348,15 @@ def _walk_env_template(builder: _Builder, path: Path) -> None:
|
|
|
1254
1348
|
return
|
|
1255
1349
|
file = _rel(path, builder.repo)
|
|
1256
1350
|
directory = _rel(path.parent, builder.repo)
|
|
1351
|
+
entries = _template_entries(text)
|
|
1352
|
+
consumers: dict[str, list[str]] | None = None
|
|
1257
1353
|
if directory == "":
|
|
1354
|
+
consumers = _root_consumers(builder, file, entries)
|
|
1258
1355
|
sources = sorted(builder.components)
|
|
1356
|
+
if consumers is None and len(sources) > 1:
|
|
1357
|
+
builder.warnings.append(
|
|
1358
|
+
f"{file}: no compose service names its variables or reads it; "
|
|
1359
|
+
f"its names are attributed to every component")
|
|
1259
1360
|
else:
|
|
1260
1361
|
owner = builder.owner(directory)
|
|
1261
1362
|
sources = [owner] if owner else []
|
|
@@ -1266,19 +1367,9 @@ def _walk_env_template(builder: _Builder, path: Path) -> None:
|
|
|
1266
1367
|
# the names belong to, so the edges have somewhere to start.
|
|
1267
1368
|
sources = [builder.add_component(
|
|
1268
1369
|
".", "", ".", _provenance(file, "env.template", 1))]
|
|
1269
|
-
for
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
continue
|
|
1273
|
-
if line.startswith("export "):
|
|
1274
|
-
line = line[len("export "):].strip()
|
|
1275
|
-
if "=" not in line:
|
|
1276
|
-
continue
|
|
1277
|
-
name, _, raw_value = line.partition("=")
|
|
1278
|
-
name = name.strip()
|
|
1279
|
-
if len(raw_value) >= 2 and raw_value[0] == raw_value[-1] and raw_value[0] in ("'", '"'):
|
|
1280
|
-
raw_value = raw_value[1:-1]
|
|
1281
|
-
builder.apply_env_name(name, raw_value, lineno, file, sources, "env.template")
|
|
1370
|
+
for name, raw_value, lineno in entries:
|
|
1371
|
+
named = consumers.get(name, sources) if consumers is not None else sources
|
|
1372
|
+
builder.apply_env_name(name, raw_value, lineno, file, named, "env.template")
|
|
1282
1373
|
|
|
1283
1374
|
|
|
1284
1375
|
def _sha(repo: Path) -> str:
|
|
@@ -1333,9 +1424,11 @@ def build_record(start: Path, *, project_id: str = "") -> tuple[dict, list[str]]
|
|
|
1333
1424
|
for path in files:
|
|
1334
1425
|
if _is_manifest(path):
|
|
1335
1426
|
_walk_manifest(builder, path)
|
|
1336
|
-
for path in files
|
|
1337
|
-
|
|
1338
|
-
|
|
1427
|
+
templates = [path for path in files if path.name == ".env.example"]
|
|
1428
|
+
for path in templates:
|
|
1429
|
+
_note_template(builder, path)
|
|
1430
|
+
for path in templates:
|
|
1431
|
+
_walk_env_template(builder, path)
|
|
1339
1432
|
if not (builder.components or builder.infra or builder.external or builder.unresolved):
|
|
1340
1433
|
raise MapError("nothing to map: no compose service, Dockerfile or known dependency")
|
|
1341
1434
|
return builder.finish(sha), builder.warnings
|
|
@@ -537,3 +537,184 @@ def test_ignored_and_untracked_files_are_not_mapped(tmp_path):
|
|
|
537
537
|
(repo / stray / "Dockerfile").write_text("FROM python:3.12\n", encoding="utf-8")
|
|
538
538
|
record, _ = stackmap.build_record(repo)
|
|
539
539
|
assert [n["dir"] for n in record["components"]] == ["app"]
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _root_template_repo(tmp_path, compose: str | None) -> Path:
|
|
543
|
+
repo = tmp_path / "rooted"
|
|
544
|
+
repo.mkdir()
|
|
545
|
+
for name in ("api", "web"):
|
|
546
|
+
(repo / name).mkdir()
|
|
547
|
+
(repo / name / "requirements.txt").write_text("stripe==8.0\n", encoding="utf-8")
|
|
548
|
+
if compose is not None:
|
|
549
|
+
(repo / "compose.yml").write_text(compose, encoding="utf-8")
|
|
550
|
+
(repo / ".env.example").write_text("OPENAI_API_KEY=\n", encoding="utf-8")
|
|
551
|
+
_commit(repo)
|
|
552
|
+
return repo
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def test_a_root_template_name_goes_to_the_service_that_declares_it(tmp_path):
|
|
556
|
+
"""GRPH-1104: a root .env.example drew web -> openai when only api reads it."""
|
|
557
|
+
repo = _root_template_repo(tmp_path,
|
|
558
|
+
"services:\n"
|
|
559
|
+
" api:\n"
|
|
560
|
+
" build: ./api\n"
|
|
561
|
+
" environment:\n"
|
|
562
|
+
" - OPENAI_API_KEY\n"
|
|
563
|
+
" web:\n"
|
|
564
|
+
" build: ./web\n")
|
|
565
|
+
record, warnings = stackmap.build_record(repo)
|
|
566
|
+
to_openai = [(e["from"], e["to"]) for e in record["edges"] if e["to"] == "openai"]
|
|
567
|
+
assert to_openai == [("api", "openai")]
|
|
568
|
+
assert not [e for e in record["edges"] if e["from"] == "web" and e["to"] != "stripe"]
|
|
569
|
+
assert warnings == []
|
|
570
|
+
assert canonical(record) == canonical(stackmap.build_record(repo)[0])
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def test_a_root_template_with_no_compose_goes_to_every_component_and_says_so(tmp_path):
|
|
574
|
+
repo = _root_template_repo(tmp_path, None)
|
|
575
|
+
record, warnings = stackmap.build_record(repo)
|
|
576
|
+
assert sorted(n["id"] for n in record["components"]) == ["api", "web"]
|
|
577
|
+
assert sorted(e["from"] for e in record["edges"] if e["to"] == "openai") == ["api", "web"]
|
|
578
|
+
assert [w for w in warnings if ".env.example" in w]
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def test_a_root_template_read_through_env_file_goes_to_its_reader(tmp_path):
|
|
582
|
+
repo = _root_template_repo(tmp_path,
|
|
583
|
+
"services:\n"
|
|
584
|
+
" api:\n"
|
|
585
|
+
" build: ./api\n"
|
|
586
|
+
" web:\n"
|
|
587
|
+
" build: ./web\n"
|
|
588
|
+
" env_file: .env.example\n")
|
|
589
|
+
record, warnings = stackmap.build_record(repo)
|
|
590
|
+
assert [e["from"] for e in record["edges"] if e["to"] == "openai"] == ["web"]
|
|
591
|
+
assert warnings == []
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
@pytest.mark.parametrize("worker_env", [
|
|
595
|
+
" environment:\n - OPENAI_API_KEY\n",
|
|
596
|
+
" env_file: .env.example\n",
|
|
597
|
+
])
|
|
598
|
+
def test_a_root_template_named_only_by_an_image_service_is_not_every_component(
|
|
599
|
+
tmp_path, worker_env):
|
|
600
|
+
"""GRPH-1104 bounce: a service that builds nothing still counts as compose naming it."""
|
|
601
|
+
repo = tmp_path / "imaged"
|
|
602
|
+
repo.mkdir()
|
|
603
|
+
for name in ("api", "web"):
|
|
604
|
+
(repo / name).mkdir()
|
|
605
|
+
(repo / name / "requirements.txt").write_text("stripe==8.0\n", encoding="utf-8")
|
|
606
|
+
(repo / "compose.yml").write_text(
|
|
607
|
+
"services:\n"
|
|
608
|
+
" api:\n"
|
|
609
|
+
" build: ./api\n"
|
|
610
|
+
" web:\n"
|
|
611
|
+
" build: ./web\n"
|
|
612
|
+
" worker:\n"
|
|
613
|
+
" image: alpine\n" + worker_env, encoding="utf-8")
|
|
614
|
+
(repo / ".env.example").write_text("OPENAI_API_KEY=\nANTHROPIC_API_KEY=\n",
|
|
615
|
+
encoding="utf-8")
|
|
616
|
+
_commit(repo)
|
|
617
|
+
record, warnings = stackmap.build_record(repo)
|
|
618
|
+
assert not [e for e in record["edges"]
|
|
619
|
+
if e["from"] in ("api", "web") and e["to"] in ("openai", "anthropic")]
|
|
620
|
+
assert [e["from"] for e in record["edges"] if e["to"] == "openai"] == ["worker"]
|
|
621
|
+
assert not [w for w in warnings if ".env.example" in w]
|
|
622
|
+
assert {"openai", "anthropic"} <= {n["id"] for n in record["external"]}
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def test_this_repo_root_template_draws_no_web_edge_to_a_model_provider():
|
|
626
|
+
record, _ = stackmap.build_record(ROOT)
|
|
627
|
+
providers = {"anthropic", "openai", stackmap.LOCAL_NODE}
|
|
628
|
+
assert not [e for e in record["edges"] if e["from"] == "web" and e["to"] in providers]
|
|
629
|
+
assert _edge(record, "web", "api", "depends_on")
|
|
630
|
+
ids = {n["id"] for n in record["external"] + record["infrastructure"]}
|
|
631
|
+
assert providers <= ids
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
# ---- what a post says it did (GRPH-1103) ----------------------------------------------------
|
|
635
|
+
# A real HTTP server on loopback, so the confirmation and the refusal go through the same
|
|
636
|
+
# transport and error parsing a person's post does.
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
@pytest.fixture()
|
|
640
|
+
def fake_server():
|
|
641
|
+
import threading
|
|
642
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
643
|
+
|
|
644
|
+
state = {"requests": [], "status": 200, "reply": {}}
|
|
645
|
+
|
|
646
|
+
class Handler(BaseHTTPRequestHandler):
|
|
647
|
+
def do_POST(self): # noqa: N802
|
|
648
|
+
raw = self.rfile.read(int(self.headers.get("Content-Length") or 0))
|
|
649
|
+
state["requests"].append((self.path, self.headers.get("X-API-Key"), json.loads(raw)))
|
|
650
|
+
reply = state["reply"](json.loads(raw)) if callable(state["reply"]) else state["reply"]
|
|
651
|
+
data = json.dumps(reply).encode()
|
|
652
|
+
self.send_response(state["status"])
|
|
653
|
+
self.send_header("Content-Type", "application/json")
|
|
654
|
+
self.send_header("Content-Length", str(len(data)))
|
|
655
|
+
self.end_headers()
|
|
656
|
+
self.wfile.write(data)
|
|
657
|
+
|
|
658
|
+
def log_message(self, *_args):
|
|
659
|
+
pass
|
|
660
|
+
|
|
661
|
+
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
|
662
|
+
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
663
|
+
thread.start()
|
|
664
|
+
state["url"] = f"http://127.0.0.1:{server.server_address[1]}"
|
|
665
|
+
try:
|
|
666
|
+
yield state
|
|
667
|
+
finally:
|
|
668
|
+
server.shutdown()
|
|
669
|
+
server.server_close()
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def _mapped_app(isolated, monkeypatch) -> tuple[Path, str]:
|
|
673
|
+
repo = isolated / "app"
|
|
674
|
+
repo.mkdir()
|
|
675
|
+
_write_app(repo)
|
|
676
|
+
sha = _commit(repo)
|
|
677
|
+
monkeypatch.setenv("GBFLEET_API_KEY", "gb_sk_seat")
|
|
678
|
+
return repo, sha
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def test_a_real_post_says_it_posted_and_where(isolated, monkeypatch, capsys, fake_server):
|
|
682
|
+
repo, sha = _mapped_app(isolated, monkeypatch)
|
|
683
|
+
fake_server["reply"] = lambda body: {**body, "mapped_at": "2026-10-23T10:00:00Z",
|
|
684
|
+
"posted_by": "seat-1"}
|
|
685
|
+
assert main(["--server", fake_server["url"], "--project", "demo",
|
|
686
|
+
"stack", "map", "--repo", str(repo)]) == 0
|
|
687
|
+
assert len(fake_server["requests"]) == 1
|
|
688
|
+
captured = capsys.readouterr()
|
|
689
|
+
assert captured.out == canonical(fake_server["requests"][0][2])
|
|
690
|
+
assert (f"gban: posted demo app map at {sha[:7]} to {fake_server['url']}"
|
|
691
|
+
in captured.err)
|
|
692
|
+
assert "posted_by seat-1" in captured.err
|
|
693
|
+
assert "dry run" not in captured.err
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def test_dry_run_says_nothing_was_posted(isolated, monkeypatch, capsys, fake_server):
|
|
697
|
+
repo, _ = _mapped_app(isolated, monkeypatch)
|
|
698
|
+
assert main(["--server", fake_server["url"], "--project", "demo",
|
|
699
|
+
"stack", "map", "--dry-run", "--repo", str(repo)]) == 0
|
|
700
|
+
captured = capsys.readouterr()
|
|
701
|
+
assert fake_server["requests"] == []
|
|
702
|
+
assert "gban: dry run, nothing posted" in captured.err
|
|
703
|
+
assert "posted demo" not in captured.err
|
|
704
|
+
assert json.loads(captured.out)["project_id"] == "demo"
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def test_a_refused_post_prints_the_reasons_and_not_the_record(isolated, monkeypatch, capsys,
|
|
708
|
+
fake_server):
|
|
709
|
+
repo, _ = _mapped_app(isolated, monkeypatch)
|
|
710
|
+
fake_server["status"] = 422
|
|
711
|
+
fake_server["reply"] = {"detail": [{"path": "edges[2].to", "reason": "unresolved node"},
|
|
712
|
+
{"path": "mapped_sha", "reason": "unknown is not a commit"}]}
|
|
713
|
+
assert main(["--server", fake_server["url"], "--project", "demo",
|
|
714
|
+
"stack", "map", "--repo", str(repo)]) == EXIT_REFUSED
|
|
715
|
+
assert len(fake_server["requests"]) == 1
|
|
716
|
+
captured = capsys.readouterr()
|
|
717
|
+
assert captured.out == ""
|
|
718
|
+
assert "edges[2].to: unresolved node" in captured.err
|
|
719
|
+
assert "mapped_sha: unknown is not a commit" in captured.err
|
|
720
|
+
assert "posted demo" not in captured.err
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|