Skip to content
Merged
74 changes: 71 additions & 3 deletions scaffold-project.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@
COMMIT_RE, NEUTRAL_PRODUCT_OWNER, PROJECT_ID_RE, TREE_DIGEST_DEFINITION,
VISIBILITY_CHOICES, NamingPolicy, Refusal, accepts_role, checked_value,
git_out, link_pins_from_trees, repo_basename, tree_digest,
# Which `--pin` products share a `SHAPE_PIN_SOURCE_<PRODUCT>` variable: the
# one answer, which the project's own `validate-pins.py` reports on (#275).
PIN_SOURCE_ENV_PREFIX, pin_source_env_collisions,
)
from shape_materialize import ( # noqa: E402
RULESET_HINT, SHAPE_REPOSITORY, CommandFailed,
Expand Down Expand Up @@ -513,6 +516,19 @@
"each product once, by the name of the repository it pins."
)

#: What a refused pair of `--pin`s whose products share a source variable is
#: told to do (#275), after what `PIN_COLLISION_REMEDIATION` says when a file
#: collides too.
PIN_VARIABLE_REMEDIATION = (
f"`{PIN_SOURCE_ENV_PREFIX}<PRODUCT>` names the local checkout of a "
"product, and is its name upper-cased with every character outside `A-Z`, "
"`0-9` and `_` turned into `_`, so names that differ only in `-`, `.` or "
"`_` share one. The project's own `validate-pins.py` reports such a pair "
"as `neutral-pin-source-collides`, and a variable two pins share is read "
"for neither. Pass one product of each such group, or products whose "
"names differ by more."
)


def _refuse_colliding_pins(pins, owner: str) -> None:
"""No `--pin` is written where the shape writes a file of its own, and no
Expand Down Expand Up @@ -553,24 +569,33 @@
contracts` was told it contains that file "and 4 more" where there
were three (#197's re-review, N1). Refused first, no pin path it lists
is the shape's or listed twice.

TWO PINS THAT SHARE A SOURCE VARIABLE ARE REFUSED HERE TOO (#275), in the
same refusal and after the file collisions, by `_colliding_pin_variables`:
`--pin open-glass@<A> --pin open_glass@<B>` are two files and one
`SHAPE_PIN_SOURCE_OPEN_GLASS`. The refusal is the one the project's own
`validate-pins.py` would make as a finding, said before three repositories
exist.
"""
# Folded on both sides, as `path_overlap` folds the leg paths: a future
# mixed-case shape file under `contracts/` is still one path with a
# lowercased pin on the case-insensitive disk of macOS and Windows.
shape_files = {shape_file.casefold()
for shape_file in assembly_root_paths()}
written: dict[str, list[tuple[str, str]]] = {}
typed_pins: list[tuple[str, str]] = []
for raw in pins:
product = parse_pin(raw, owner)[0]
path = neutral_pin_path(product)
written.setdefault(path, []).append((raw, product))
typed_pins.append((raw, product))
collisions = []
for path, typed in written.items():
in_shape = path.casefold() in shape_files
if not in_shape and len(typed) == 1:
continue
products = list(dict.fromkeys(product for _, product in typed))
said = (" and ".join(f"--pin {raw!r}" for raw, _ in typed)

Check failure on line 598 in scaffold-project.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal " and " 4 times.

See more on https://sonarcloud.io/project/issues?id=opensoft_openRepoShape&issues=AaEw6qkEllyfkB07NwLe&open=AaEw6qkEllyfkB07NwLe&pullRequest=409
+ (" writes the pin" if len(typed) == 1 else " write the pins")
+ " of the product" + ("s" if len(products) > 1 else "") + " "
+ " and ".join(f"`{product}`" for product in products))
Expand All @@ -583,9 +608,52 @@
collisions.append(
f"{said} at one file, `{path}`, which holds one pin: pass "
"each product once.")
if collisions:
raise Refusal("scaffold-pin-collides", " ".join(collisions),
PIN_COLLISION_REMEDIATION)
shared = _colliding_pin_variables(typed_pins)
if collisions or shared:
raise Refusal("scaffold-pin-collides", " ".join(collisions + shared),
_pin_collision_remediation(bool(collisions),
bool(shared)))


def _colliding_pin_variables(typed_pins) -> list[str]:
"""One sentence for each `SHAPE_PIN_SOURCE_<PRODUCT>` variable that two or
more of the products `--pin` names share, each argument as it was typed,
in the order each variable's first `--pin` was (#275).

`open-glass`, `open_glass` and `open.glass` are three pin files and three
legal `--pin` names, and one variable: whichever checkout it names answers
for all of them, and the project's own `validate-pins.py` reported the
pin it was not a checkout of as `neutral-pin-source-unreadable`. The
question is `repo_shape.pin_source_env_collisions`'s, so it is the one
`validate-pins.py` answers, and it takes products as their pin files do:
the same product typed twice is #241's `one file`, said above, and is
never a collision here.
"""
typed = {}
for raw, product in typed_pins:
typed.setdefault(product.casefold(), []).append(raw)
out = []
for variable, products in pin_source_env_collisions(
product for _, product in typed_pins).items():
arguments = [f"--pin {raw!r}" for product in products
for raw in typed[product.casefold()]]
out.append(
" and ".join(arguments) + " name the products "
+ " and ".join(f"`{product}`" for product in products)
+ f", and one variable, `{variable}`, names the local checkout of "
"each: it cannot say which it holds.")
return out


def _pin_collision_remediation(file_collision: bool,
variable_collision: bool) -> str:
"""The remediation for the collisions a refusal names: #241's alone for a
file, the variable's alone, or both said once each."""
if not variable_collision:
return PIN_COLLISION_REMEDIATION
if not file_collision:
return f"Remediation: nothing was created. {PIN_VARIABLE_REMEDIATION}"
return f"{PIN_COLLISION_REMEDIATION} {PIN_VARIABLE_REMEDIATION}"


def _leg_collision_remediation(neutral_pins=()) -> str:
Expand Down
103 changes: 98 additions & 5 deletions scripts/repo_shape.py
Original file line number Diff line number Diff line change
Expand Up @@ -1175,13 +1175,85 @@ def recorded_gitlink(repo: Path, path: str) -> str | None:
#: ONE variable answers both questions asked of such a checkout: the digest
#: `validate-pins.py` recomputes from it, and the chain-link declaration
#: `resolve_referent` verifies against it.
#:
#: THE NAME IS NOT INJECTIVE (#275). `.`, `-` and `_` are all legal in a
#: product's name and all become `_`, so `open-glass`, `open_glass` and
#: `open.glass` share `SHAPE_PIN_SOURCE_OPEN_GLASS` while naming three pin
#: files. Every existing name is kept, because an operator may have set it and
#: the docs name it. What is done instead (`pin_source_env_collisions`): a
#: variable two declared products share answers for NEITHER, wherever it is
#: read; `scaffold-project.py --pin` refuses to create such a pair; and
#: `validate-pins.py` NOTES a pair a project already declares, and never fails
#: it, because the project did nothing wrong that an update could undo.
PIN_SOURCE_ENV_PREFIX = "SHAPE_PIN_SOURCE_"


def pin_source_env_name(product: str) -> str:
return PIN_SOURCE_ENV_PREFIX + re.sub(r"[^A-Z0-9_]", "_", product.upper())


def pin_product_key(product: str) -> str:
"""What makes two declarations ONE product: the name its pin file carries,
`contracts/<product lowercased>-pin.yaml` (`neutral_pin_path` in
`shape_materialize.py` writes it; `validate-pins.py` reads it, and spells
it again because it ships into projects).

`lower()`, and not `casefold()`. The two agree over ASCII and part
company outside it: `casefold()` folds `ß` to `ss`, `ſ` to `s`, `fi` to
`fi` and `ς` to `σ`, which `lower()` leaves alone, so `openß` and `openss`
are two pin files that a casefold took for one product (#275's re-review,
N2). Whether two names are the same pin is whether they are the same
file, so that is what is asked here, and
`tests/test_pin_source_env_collisions.py` holds it to `neutral_pin_path`.
"""
return product.lower()


def pin_source_env_collisions(products) -> dict[str, list[str]]:
"""The variables two or more DISTINCT products share, each with them:
`{variable: [product, ...]}` (#275).

PURE, and the one place the question is answered, so `scaffold-project.py`,
`validate-pins.py` and the chain readers below cannot disagree about it.
`products` is any iterable of product NAMES (`openGlass`, never
`opensoft/openGlass@<sha>`): an entry that is not a non-empty string is
not a name and is left out, as `validate-pins.py` reports it as
malformed on its own.

DISTINCT MEANS DISTINCT PIN FILES (`pin_product_key`): `openGlass` and
`OpenGlass` are one product and name one variable rightly, and whether the
same product is declared twice is a different question, which
`scaffold-project.py` answers as `scaffold-pin-collides` (#241). A product
listed twice is therefore never a collision here, and two that differ in
more than the case `lower()` folds are, when `pin_source_env_name` maps
them to one name. That includes names no ASCII `--pin` can spell, such as
`openß` and `openss`, which share `SHAPE_PIN_SOURCE_OPENSS` and have two
pin files.

Variables are in the order their first product was given, and each
group's products are in that order too, each in the spelling it was first
given in. A variable only one product names is not in the result.
"""
by_variable: dict[str, dict[str, str]] = {}
for product in products or ():
if not isinstance(product, str) or not product:
continue
group = by_variable.setdefault(pin_source_env_name(product), {})
group.setdefault(pin_product_key(product), product)
return {variable: list(group.values())
for variable, group in by_variable.items() if len(group) > 1}


def pin_source_env_shared(products) -> set[str]:
"""The `pin_product_key` of each of `products` whose variable another of
them shares (#275): the products a variable must not answer for, because
it does not say which. A caller asks `pin_product_key(name) in` the
result."""
return {pin_product_key(product)
for group in pin_source_env_collisions(products).values()
for product in group}


def form_id(family_id: str, role_id: str | None) -> str:
"""`("project-leg", "assembly")` -> `"project-leg/assembly"`."""
return f"{family_id}/{role_id}" if role_id else family_id
Expand Down Expand Up @@ -1335,15 +1407,20 @@ def _chain_link_findings(chain, link_pins) -> tuple:
available = _link_declarations(link_pins)
warnings: list[str] = []
unverified: list[str] = []
# A link whose variable another link of the chain shares is never offered
# that variable: it would be read for both (#275).
shared = pin_source_env_shared(chain)
for holder, held in zip(chain, chain[1:]):
declared = available.get(holder.casefold())
if declared is None:
unverified.append(holder)
alone = pin_product_key(holder) not in shared
offered = (f"a sibling checkout, {pin_source_env_name(holder)}, or"
if alone else "a sibling checkout or")
warnings.append(
f"{CHAIN_UNVERIFIED}: {holder} declaring a pin on {held} "
f"is a fact in {holder}'s own tree, which is not "
f"reachable here (a sibling checkout, "
f"{pin_source_env_name(holder)}, or --link-source "
f"reachable here ({offered} --link-source "
f"{holder}=<path> would let it be read). The recorded "
"chain still classifies.")
continue
Expand Down Expand Up @@ -2511,20 +2588,26 @@ def declared_neutral_pins(manifest_path,

def resolve_link_source(product: str, root: Path | None = None,
keyed_sources: dict | None = None,
default_source=None) -> tuple[Path | None, str | None]:
default_source=None,
env_shared: bool = False
) -> tuple[Path | None, str | None]:
"""Where `product`'s own manifest can be read, if anywhere at all.

1. `--link-source <product>=<path>` (case-insensitive on the name)
2. `SHAPE_PIN_SOURCE_<PRODUCT>` in the environment
3. a checkout sitting BESIDE this project (`../openXdox`)
4. a bare `--link-source <path>` with no `product=` prefix

`env_shared` says another product asked about alongside this one names
the same variable, so rule 2 is skipped: a variable that does not say
which product it holds is not read for either (#275).
"""
candidates: list[tuple[Path, str]] = []
keyed = (keyed_sources or {}).get(product.casefold())
if keyed is not None:
candidates.append((Path(keyed), f"--link-source {product}=..."))
env_name = pin_source_env_name(product)
env_value = os.environ.get(env_name)
env_value = None if env_shared else os.environ.get(env_name)
if env_value:
candidates.append((Path(env_value), f"${env_name}"))
if root is not None:
Expand All @@ -2546,12 +2629,22 @@ def link_pins_from_trees(links, root: Path | None = None,

A link whose tree is not on the disk is simply ABSENT from the mapping,
which is what `NamingPolicy.resolve_referent` reads as declared-unverified.

A link whose `SHAPE_PIN_SOURCE_<PRODUCT>` another of `links` names too is
not read through it (#275): one tree would answer for two links, and a
chain verified against the wrong tree is worse than one left
declared-unverified. Only a hand-written chain can hold two such links:
`scaffold-project.py` takes every link but the first from the naming
policy's `open<Product>` form, which has no `-`, `.` or `_` to fold.
"""
found: dict[str, set] = {}
links = list(links)
shared = pin_source_env_shared(repo_basename(str(link)) for link in links)
for link in links:
name = repo_basename(str(link))
path, _how = resolve_link_source(name, root, keyed_sources,
default_source)
default_source,
pin_product_key(name) in shared)
if path is None:
continue
pins = declared_neutral_pins(path, key)
Expand Down
Loading
Loading