Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/fs-cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 28 additions & 3 deletions fs_cli/configured_studios.py
Original file line number Diff line number Diff line change
Expand Up @@ -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_]*$")
Expand Down Expand Up @@ -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,
{
Expand All @@ -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
Expand All @@ -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 = {
Expand Down
3 changes: 2 additions & 1 deletion fs_cli/git.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
part_set = "fasteners"
bump = "minor"
bump = "patch"
description = "WCP's new #10-32 socket and button head lengths"
48 changes: 48 additions & 0 deletions tests/configured_studios_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}}


Expand Down Expand Up @@ -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"
Expand Down
Loading