adopt-map 0.4.0__tar.gz → 0.4.2__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.
- {adopt_map-0.4.0 → adopt_map-0.4.2}/PKG-INFO +1 -1
- {adopt_map-0.4.0 → adopt_map-0.4.2}/pyproject.toml +1 -1
- {adopt_map-0.4.0 → adopt_map-0.4.2}/src/adopt_map/packs/generic.py +117 -8
- {adopt_map-0.4.0 → adopt_map-0.4.2}/src/adopt_map/runner.py +10 -1
- {adopt_map-0.4.0 → adopt_map-0.4.2}/.gitignore +0 -0
- {adopt_map-0.4.0 → adopt_map-0.4.2}/LICENSE +0 -0
- {adopt_map-0.4.0 → adopt_map-0.4.2}/NOTICE +0 -0
- {adopt_map-0.4.0 → adopt_map-0.4.2}/src/adopt_map/__init__.py +0 -0
- {adopt_map-0.4.0 → adopt_map-0.4.2}/src/adopt_map/diff.py +0 -0
- {adopt_map-0.4.0 → adopt_map-0.4.2}/src/adopt_map/digest.py +0 -0
- {adopt_map-0.4.0 → adopt_map-0.4.2}/src/adopt_map/expected.py +0 -0
- {adopt_map-0.4.0 → adopt_map-0.4.2}/src/adopt_map/filestate.py +0 -0
- {adopt_map-0.4.0 → adopt_map-0.4.2}/src/adopt_map/keys.py +0 -0
- {adopt_map-0.4.0 → adopt_map-0.4.2}/src/adopt_map/moves.py +0 -0
- {adopt_map-0.4.0 → adopt_map-0.4.2}/src/adopt_map/observation.py +0 -0
- {adopt_map-0.4.0 → adopt_map-0.4.2}/src/adopt_map/observe.py +0 -0
- {adopt_map-0.4.0 → adopt_map-0.4.2}/src/adopt_map/packs/__init__.py +0 -0
- {adopt_map-0.4.0 → adopt_map-0.4.2}/src/adopt_map/packs/ai.py +0 -0
- {adopt_map-0.4.0 → adopt_map-0.4.2}/src/adopt_map/packs/web.py +0 -0
- {adopt_map-0.4.0 → adopt_map-0.4.2}/src/adopt_map/py.typed +0 -0
- {adopt_map-0.4.0 → adopt_map-0.4.2}/src/adopt_map/report.py +0 -0
- {adopt_map-0.4.0 → adopt_map-0.4.2}/src/adopt_map/tree.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: adopt-map
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.2
|
|
4
4
|
Summary: adopt map: deterministic identity extraction from a repository. Build 1.
|
|
5
5
|
Project-URL: Homepage, https://github.com/onboardux/onboard-core
|
|
6
6
|
Project-URL: Source, https://github.com/onboardux/onboard-core
|
|
@@ -13,11 +13,12 @@ dependency.
|
|
|
13
13
|
"""
|
|
14
14
|
|
|
15
15
|
import ast
|
|
16
|
+
import hashlib
|
|
16
17
|
import json
|
|
17
18
|
import re
|
|
18
19
|
import tomllib
|
|
19
20
|
from collections.abc import Iterator
|
|
20
|
-
from typing import Final
|
|
21
|
+
from typing import Any, Final
|
|
21
22
|
|
|
22
23
|
import yaml
|
|
23
24
|
|
|
@@ -400,10 +401,21 @@ class CiWorkflowExtractor:
|
|
|
400
401
|
A workflow is a scheduled/triggered job in every sense that matters to an
|
|
401
402
|
FDE: it is how the system gets built, tested and deployed, and "what runs on
|
|
402
403
|
merge" is one of the first questions a handover has to answer.
|
|
404
|
+
|
|
405
|
+
**`recipe_digest` is what makes a changed pipeline a changed referent**
|
|
406
|
+
(version `2`). Version `1` carried only the workflow name, its job names and
|
|
407
|
+
its triggers, so replacing `alembic upgrade head` with a different migration
|
|
408
|
+
command -- the step a redeploy runbook exists to describe -- was classified
|
|
409
|
+
`BINDING_INTACT_RENDER_ONLY` and the runbook stayed fresh. The digest covers
|
|
410
|
+
each job's `needs`, `runs-on` and steps (`run`, `uses`, `with`), taken from
|
|
411
|
+
the *parsed* document so a comment never reaches it, with whitespace inside
|
|
412
|
+
a `run` collapsed so re-indenting a script is not a change (H5). Step names
|
|
413
|
+
are left out: renaming a step changes nothing that runs. The digest, not the
|
|
414
|
+
commands, is what is kept, so no line of the client's pipeline is stored.
|
|
403
415
|
"""
|
|
404
416
|
|
|
405
417
|
name = "generic.ci"
|
|
406
|
-
version = "
|
|
418
|
+
version = "2"
|
|
407
419
|
|
|
408
420
|
def extract(self, tree: SourceTree) -> Iterator[Observation]:
|
|
409
421
|
for entry in tree.files:
|
|
@@ -435,6 +447,7 @@ class CiWorkflowExtractor:
|
|
|
435
447
|
"name": workflow,
|
|
436
448
|
"jobs": job_names,
|
|
437
449
|
"triggers": _trigger_names(triggers),
|
|
450
|
+
"recipe_digest": _ci_recipe_digest(jobs),
|
|
438
451
|
},
|
|
439
452
|
span=_whole_file(entry),
|
|
440
453
|
)
|
|
@@ -447,11 +460,25 @@ class FilesOfInterestExtractor:
|
|
|
447
460
|
list is deliberately short: its value is that everything on it is worth
|
|
448
461
|
someone's attention, and that property is lost the moment it grows to
|
|
449
462
|
include whatever happened to be present.
|
|
463
|
+
|
|
464
|
+
**Two kinds of file live on this list, and only one of them is a recipe.**
|
|
465
|
+
A README, a licence or a changelog says *that* something exists; a reworded
|
|
466
|
+
one changes no fact about the system, so those stay name-only. A Dockerfile,
|
|
467
|
+
a compose file, a Makefile or a Procfile *is* how the system is built and
|
|
468
|
+
started: replacing `python:3.12-slim` with `node:20-alpine` is the change a
|
|
469
|
+
redeploy runbook most needs to hear about, and until version `3` it was
|
|
470
|
+
classified `BINDING_INTACT_RENDER_ONLY` because the identity carried only
|
|
471
|
+
the file's name. Those four now carry `recipe_digest` -- a digest of their
|
|
472
|
+
*instructions*, with comments and blank lines dropped and whitespace
|
|
473
|
+
collapsed (compose is parsed, so YAML comments never reach it), which keeps
|
|
474
|
+
H5's promise that a comment-only edit manufactures no staleness. The digest
|
|
475
|
+
is stored, never the content.
|
|
450
476
|
"""
|
|
451
477
|
|
|
452
478
|
name = "generic.files_of_interest"
|
|
453
479
|
#: Bumped to `2` in S1.2: the attribute set lost `path`. See `extract`.
|
|
454
|
-
|
|
480
|
+
#: Bumped to `3`: build/deploy recipes gained `recipe_digest`. See the class.
|
|
481
|
+
version = "3"
|
|
455
482
|
|
|
456
483
|
_WELL_KNOWN: Final[frozenset[str]] = frozenset(
|
|
457
484
|
{
|
|
@@ -469,18 +496,31 @@ class FilesOfInterestExtractor:
|
|
|
469
496
|
}
|
|
470
497
|
)
|
|
471
498
|
|
|
499
|
+
#: The recipes among them: files whose instructions are how the system is
|
|
500
|
+
#: built or started. See the class docstring.
|
|
501
|
+
_RECIPES: Final[frozenset[str]] = frozenset(
|
|
502
|
+
{"Dockerfile", "docker-compose.yml", "docker-compose.yaml", "Makefile", "Procfile"}
|
|
503
|
+
)
|
|
504
|
+
|
|
472
505
|
def extract(self, tree: SourceTree) -> Iterator[Observation]:
|
|
473
506
|
for entry in tree.files:
|
|
474
507
|
if entry.name not in self._WELL_KNOWN:
|
|
475
508
|
continue
|
|
509
|
+
attributes: dict[str, object] = {"name": entry.name}
|
|
510
|
+
if entry.name in self._RECIPES:
|
|
511
|
+
text = tree.text(entry)
|
|
512
|
+
if text is not None:
|
|
513
|
+
attributes["recipe_digest"] = _recipe_digest(entry.name, text)
|
|
476
514
|
yield Observation(
|
|
477
515
|
kind="metadata_component",
|
|
478
516
|
key=path_key(entry.path),
|
|
479
517
|
namespace=FILE_NAMESPACE,
|
|
480
|
-
# Deliberately **not** the file's content
|
|
481
|
-
#
|
|
482
|
-
#
|
|
483
|
-
#
|
|
518
|
+
# Deliberately **not** the file's content: this identity says
|
|
519
|
+
# "this repository has a README", and a reworded README is not a
|
|
520
|
+
# change to that fact. Content-bearing knowledge is Build 2's
|
|
521
|
+
# `adopt ingest`, not Build 1's inventory. The exception is the
|
|
522
|
+
# recipes, which carry a digest of their instructions (class
|
|
523
|
+
# docstring) -- a digest, so the content still never lands here.
|
|
484
524
|
#
|
|
485
525
|
# **And deliberately not the path**, which this carried until
|
|
486
526
|
# S1.2. The path is where the referent sits, and `Observation`'s
|
|
@@ -492,11 +532,80 @@ class FilesOfInterestExtractor:
|
|
|
492
532
|
# keeps the old URI resolvable is never written. An extractor
|
|
493
533
|
# that puts location in its attributes silently opts its
|
|
494
534
|
# referents out of the one mechanism protecting them.
|
|
495
|
-
attributes=
|
|
535
|
+
attributes=attributes,
|
|
496
536
|
span=_whole_file(entry),
|
|
497
537
|
)
|
|
498
538
|
|
|
499
539
|
|
|
540
|
+
def _sha256_of(value: object) -> str:
|
|
541
|
+
"""`sha256:<hex>` over a canonical JSON rendering of `value`."""
|
|
542
|
+
rendered = json.dumps(
|
|
543
|
+
value, sort_keys=True, separators=(",", ":"), ensure_ascii=False, default=str
|
|
544
|
+
)
|
|
545
|
+
return "sha256:" + hashlib.sha256(rendered.encode("utf-8")).hexdigest()
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def _instruction_lines(text: str) -> list[str]:
|
|
549
|
+
"""The lines that do something: comments and blank lines gone, spacing collapsed.
|
|
550
|
+
|
|
551
|
+
A line whose first non-blank character is `#` is a comment in a Dockerfile,
|
|
552
|
+
a Makefile and a Procfile alike. A trailing `# note` after an instruction is
|
|
553
|
+
deliberately *kept*: in a Makefile recipe it reaches the shell, and in a
|
|
554
|
+
Dockerfile `#` is only a comment at the start of a line -- so stripping it
|
|
555
|
+
would discard real arguments to keep a cosmetic edit quiet, which is the
|
|
556
|
+
wrong side to err on for a recovery claim.
|
|
557
|
+
"""
|
|
558
|
+
lines: list[str] = []
|
|
559
|
+
for raw in text.splitlines():
|
|
560
|
+
line = " ".join(raw.split())
|
|
561
|
+
if not line or line.startswith("#"):
|
|
562
|
+
continue
|
|
563
|
+
lines.append(line)
|
|
564
|
+
return lines
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def _recipe_digest(name: str, text: str) -> str:
|
|
568
|
+
"""The digest of a build/deploy recipe's instructions. See `FilesOfInterestExtractor`."""
|
|
569
|
+
if name.startswith("docker-compose."):
|
|
570
|
+
try:
|
|
571
|
+
document = yaml.safe_load(text)
|
|
572
|
+
except yaml.YAMLError:
|
|
573
|
+
document = None
|
|
574
|
+
if isinstance(document, dict):
|
|
575
|
+
return _sha256_of(document)
|
|
576
|
+
return _sha256_of(_instruction_lines(text))
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def _ci_recipe_digest(jobs: object) -> str:
|
|
580
|
+
"""The digest of what a workflow's jobs actually run. See `CiWorkflowExtractor`."""
|
|
581
|
+
if not isinstance(jobs, dict):
|
|
582
|
+
return _sha256_of({})
|
|
583
|
+
recipe: dict[str, Any] = {}
|
|
584
|
+
for job_name, job in jobs.items():
|
|
585
|
+
if not isinstance(job, dict):
|
|
586
|
+
recipe[str(job_name)] = {}
|
|
587
|
+
continue
|
|
588
|
+
needs: Any = job.get("needs")
|
|
589
|
+
steps: list[dict[str, Any]] = []
|
|
590
|
+
for step in job.get("steps") or []:
|
|
591
|
+
if not isinstance(step, dict):
|
|
592
|
+
continue
|
|
593
|
+
entry: dict[str, Any] = {}
|
|
594
|
+
if isinstance(step.get("run"), str):
|
|
595
|
+
entry["run"] = " ".join(str(step["run"]).split())
|
|
596
|
+
if step.get("uses") is not None:
|
|
597
|
+
entry["uses"] = str(step["uses"])
|
|
598
|
+
if isinstance(step.get("with"), dict):
|
|
599
|
+
entry["with"] = step["with"]
|
|
600
|
+
steps.append(entry)
|
|
601
|
+
recipe[str(job_name)] = {
|
|
602
|
+
"needs": sorted(str(n) for n in needs) if isinstance(needs, list) else needs,
|
|
603
|
+
"runs-on": job.get("runs-on"),
|
|
604
|
+
"steps": steps,
|
|
605
|
+
}
|
|
606
|
+
return _sha256_of(recipe)
|
|
607
|
+
|
|
608
|
+
|
|
500
609
|
def _flatten(data: dict[str, object], prefix: str = "") -> Iterator[tuple[str, object]]:
|
|
501
610
|
"""Leaf keys of a nested mapping, rendered dotted."""
|
|
502
611
|
for key, value in data.items():
|
|
@@ -141,9 +141,18 @@ class MapReport:
|
|
|
141
141
|
#: platform, lowcode and data are pulled by a real engagement, never pushed).
|
|
142
142
|
#: `generic` runs for every archetype -- config, dependencies and jobs exist in
|
|
143
143
|
#: every system, whatever it is built from.
|
|
144
|
+
#:
|
|
145
|
+
#: **`ai` includes `web`.** An AI system that people depend on is reached through
|
|
146
|
+
#: an API: the agent sits behind an endpoint, and the endpoint is what a recovery
|
|
147
|
+
#: runbook and an exit test name. With `ai` mapping only `generic` + `ai`, an
|
|
148
|
+
#: AI service's routes were never extracted -- and once a human had mapped them
|
|
149
|
+
#: with `--packs`, every later `refresh` or `ci-sense` without the same flag
|
|
150
|
+
#: marked them exempt ("pack not selected"), so a route rename went undetected
|
|
151
|
+
#: with exit 0. The web pack on a repository with no web framework simply finds
|
|
152
|
+
#: nothing, which costs one walk and claims nothing.
|
|
144
153
|
PACKS_FOR_ARCHETYPE: Mapping[Archetype, tuple[str, ...]] = {
|
|
145
154
|
"web": ("generic", "web"),
|
|
146
|
-
"ai": ("generic", "ai"),
|
|
155
|
+
"ai": ("generic", "web", "ai"),
|
|
147
156
|
"platform": ("generic",),
|
|
148
157
|
"lowcode": ("generic",),
|
|
149
158
|
"data": ("generic",),
|
|
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
|