diff --git a/docs/fs-cli.md b/docs/fs-cli.md index bc7fbbaa..bb79cb2a 100644 --- a/docs/fs-cli.md +++ b/docs/fs-cli.md @@ -401,7 +401,9 @@ default), creating it if it's missing, and deletes the feature a build inserted the configuration's inputs and options, and Onshape refuses a configuration that leaves them naming ones it no longer has ("The change would result in the feature list becoming invalid"). It then replaces the configuration with the definition's, which must keep the inputs' ids, inserts the feature from the Feature Studio in the same document as it -is now, with each input's default, and configures it. The configured feature keeps the namespaces +is now, with each input's default, and configures it, by the id the insert answered: a feature list read just after +the insert has been seen to fail as though it named the feature deleted before it, so the build reads it again, a second +apart, until it lists the new one, and stops after five reads. The configured feature keeps the namespaces Onshape stored on insert, for it and each of its enums: a feature from a Feature Studio re-exporting another document's (as a release's is) is stored under another namespace than it's inserted with, and Onshape refuses an update naming any other, "Feature does not match". A refusal names the diff --git a/fs_cli/configured_studios.py b/fs_cli/configured_studios.py index dc0b873d..2e366de8 100644 --- a/fs_cli/configured_studios.py +++ b/fs_cli/configured_studios.py @@ -20,6 +20,7 @@ import json import pathlib import re +import time from typing import Literal, Mapping, Sequence _IDENTIFIER = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") @@ -693,7 +694,7 @@ def step(doing: str): namespace = feature_namespace(feature_studio_id, feature_studio_microversion) # Inserted plain and configured after, so a refusal names which of the two Onshape objects to with step(f"insert {studio.feature}"): - part_studios.add_feature( + inserted = part_studios.add_feature( api, part_studio, { @@ -705,8 +706,12 @@ def step(doing: str): }, ) # Its configuration, or its insert, changed its microversion - feature_list = part_studios.get_features(api, part_studio) - feature = next(feature for feature in feature_list["features"] if feature.get("featureType") == studio.feature) + feature_list, feature = _read_inserted(api, part_studio, inserted["feature"]["featureId"]) + if feature is None: + raise ConfigureError( + f"Onshape's feature list for {studio.tab_name} still doesn't show the {studio.feature} just inserted; " + "run it again." + ) stored = feature.get("parameters", []) configured = { parameter["parameterId"]: parameter @@ -724,6 +729,26 @@ def step(doing: str): return Built(element_id, created, status) +# Read again while a feature just inserted isn't listed yet (unverified Onshape behavior, inferred from a 404 naming the +# feature deleted before it), a second apart +FEATURE_LIST_READS = 5 +FEATURE_LIST_WAIT = 1.0 + + +def _read_inserted(api, part_studio, feature_id: str) -> tuple[dict, dict | None]: + """A Part Studio's feature list, and in it the feature just inserted, by its id; None when it never shows.""" + from onshape_api.endpoints import part_studios + + for attempt in range(FEATURE_LIST_READS): + if attempt: + time.sleep(FEATURE_LIST_WAIT) + feature_list = part_studios.get_features(api, part_studio) + feature = next((each for each in feature_list["features"] if each.get("featureId") == feature_id), None) + if feature is not None: + return feature_list, feature + return feature_list, None + + def stored_namespaces(configured: list[dict], stored: list[dict]) -> list[dict]: """Configured parameters with each enum value in the namespace Onshape stored its parameter's enum under.""" namespaces = { diff --git a/fs_cli/git.py b/fs_cli/git.py index b5145748..8387ff20 100644 --- a/fs_cli/git.py +++ b/fs_cli/git.py @@ -146,7 +146,8 @@ def commit_with_contents(root: pathlib.Path, file: pathlib.Path, contents: str) def added_in(root: pathlib.Path, file: pathlib.Path) -> str | None: """The commit that last added a file, or None while it's uncommitted.""" - output = _git(root, "log", "--diff-filter=A", "-1", "--format=%H", "--", str(file)) + # A file renamed from another is added as far as its releases go + output = _git(root, "log", "--no-renames", "--diff-filter=A", "-1", "--format=%H", "--", str(file)) return output.strip() if output and output.strip() else None diff --git a/releases/fasteners.toml b/releases/fasteners-wcp-lengths.toml similarity index 85% rename from releases/fasteners.toml rename to releases/fasteners-wcp-lengths.toml index 66858a97..b9813ef4 100644 --- a/releases/fasteners.toml +++ b/releases/fasteners-wcp-lengths.toml @@ -1,3 +1,3 @@ part_set = "fasteners" -bump = "minor" +bump = "patch" description = "WCP's new #10-32 socket and button head lengths" diff --git a/tests/configured_studios_test.py b/tests/configured_studios_test.py index 9b41e9eb..949bb666 100644 --- a/tests/configured_studios_test.py +++ b/tests/configured_studios_test.py @@ -408,6 +408,7 @@ def post(self, path, body="", **kwargs): for parameter in feature["parameters"] ] self.tabs[element_id][1].append(feature) + return {"feature": feature, "featureState": {"featureStatus": self.status}} return {"featureState": {"featureStatus": self.status}} @@ -484,6 +485,53 @@ def test_build_replaces_the_feature_a_build_inserted_before(): assert all(parameter["btType"] == "BTMParameterConfigured-2222" for parameter in parameters) +def test_build_configures_the_feature_it_inserted_once_onshape_lists_it(monkeypatch): + """A feature list read just after the insert may still list the feature deleted before it.""" + import fs_cli.configured_studios as configured_studios + + monkeypatch.setattr(configured_studios, "FEATURE_LIST_WAIT", 0) + old = {"featureId": "F1", "featureType": "fastenerScrew", "namespace": "efs::mold", "parameters": []} + + class Stale(FakeApi): + stale_reads = 2 + + def get(self, path, **kwargs): + if "/features" in path and self.stale_reads > 0: + self.stale_reads -= 1 + self.requests.append(("GET", path, None)) + return {"features": [old], "sourceMicroversion": "stale"} + return super().get(path, **kwargs) + + api = Stale({"Screw": [dict(old)]}) + api.stale_reads = 0 + original_post = api.post + + def post(path, body="", **kwargs): + response = original_post(path, body, **kwargs) + if path.endswith("/features"): + api.stale_reads = 2 + return response + + api.post = post + build(api, WORKSPACE, studio(), "fs", "fsmv") + updates = [path for _, path, _ in api.requests if "/featureid/" in path and not path.endswith("F1")] + assert updates == ["/partstudios/d/d/w/w/e/e0/features/featureid/F"] + + # One that never shows is refused, rather than another feature configured + never = Stale({"Screw": []}) + never.stale_reads = 0 + + def post_never(path, body="", **kwargs): + response = FakeApi.post(never, path, body, **kwargs) + if path.endswith("/features"): + never.stale_reads = configured_studios.FEATURE_LIST_READS + return response + + never.post = post_never + with pytest.raises(ConfigureError, match="still doesn't show the fastenerScrew just inserted"): + build(never, WORKSPACE, studio(), "fs", "fsmv") + + def test_build_configures_a_feature_in_the_namespaces_onshape_stored(): # As from a Feature Studio re-exporting another document's: Onshape stores where it's defined stored = "dback::vver::eback::mbackmv"