优化流程

This commit is contained in:
2026-09-02 13:51:35 +08:00
parent 97a03c290b
commit f3eb5dff54
71 changed files with 5290 additions and 515 deletions
+40
View File
@@ -47,3 +47,43 @@ the normalized full source filename. Files that normalize to the same part ID
receive a deterministic relative-path hash suffix, so every input record maps
to a distinct output. When a matching STEP file exists, it is resolved through
the same relative subdirectory as its source record.
## Onshape API samples
`download_onshape_samples.py` downloads raw Onshape v9 feature-list responses
for a small set of public ABC Part Studios. Create a personal API key in the
Onshape developer settings, then keep the credentials out of the repository:
```bash
export ONSHAPE_ACCESS_KEY='...'
export ONSHAPE_SECRET_KEY='...'
python json_to_cdsl/download_onshape_samples.py --count 3
```
If the environment variables are absent, the program prompts without echoing
the values. Output is written under
`json_to_cdsl/input/onshape_api_samples/<abc_id>/features.json`; this input
directory is ignored by Git. Use `--url ID=URL` for another Part Studio or
`--url-file` to read ABC `objects_*.yml` mappings.
## Complete Onshape sample
`download_onshape_complete.py` saves the complete public-API representation
of one Part Studio. It is the appropriate input for building a CDSL converter:
the directory includes the feature tree, sketch definitions and constraints,
FeatureScript representation, parts, body/topology data, mass properties,
tessellations, previews, native Parasolid, STL, and an independently exported
AP242 STEP reference model.
```bash
export ONSHAPE_ACCESS_KEY='...'
export ONSHAPE_SECRET_KEY='...'
python json_to_cdsl/download_onshape_complete.py --id 00000352
```
The result is written to
`json_to_cdsl/input/onshape_complete/00000352/manifest.json`. Every successful
artifact has a byte count and SHA-256 in that manifest. Failed endpoints are
also recorded, rather than silently omitted. This is all data exposed by the
public API for the selected Part Studio; it is not an internal `.onshape`
document backup, which Onshape does not expose as a download format.
+379
View File
@@ -0,0 +1,379 @@
#!/usr/bin/env python3
"""Download every relevant public API artifact for one Onshape Part Studio.
The output is a self-describing directory for developing and validating an
Onshape-feature-tree-to-CDSL converter. It intentionally collects both the
editable source representation (features, sketches, parameters and queries)
and independent reconstruction targets (STEP, Parasolid, meshes, topology and
mass properties). Credentials are read from environment variables or hidden
terminal prompts and are never written to the output directory.
"""
from __future__ import annotations
import argparse
import base64
import getpass
import hashlib
import json
import os
import ssl
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any, Callable
from download_onshape_samples import PartStudioRef, parse_part_studio_url
API_VERSION = "v17"
DEFAULT_OUTPUT = Path("json_to_cdsl/input/onshape_complete")
DEFAULT_SAMPLE_ID = "00000352"
DEFAULT_SAMPLE_URL = (
"https://cad.onshape.com/documents/4185972a944744d8a7a0f2b4/"
"w/d82d7eef8edf4342b7e49732/e/b6d6b562e8b64e7ea50d8325"
)
JSON_ACCEPT = "application/json;charset=UTF-8; qs=0.09"
USER_AGENT = "cdsl-cad-onshape-complete-downloader/1.0"
@dataclass
class DownloadResult:
name: str
status: str
path: str | None = None
url: str | None = None
bytes: int | None = None
sha256: str | None = None
http_status: int | None = None
content_type: str | None = None
message: str | None = None
class OnshapeClient:
def __init__(self, ref: PartStudioRef, authorization: str, timeout: float) -> None:
self.ref = ref
self.authorization = authorization
self.timeout = timeout
self.context = ssl.create_default_context(cafile=self._ca_bundle())
@staticmethod
def _ca_bundle() -> str | None:
try:
import certifi
except ImportError:
return None
return certifi.where()
def url(self, path: str, query: dict[str, Any] | None = None) -> str:
encoded = urllib.parse.urlencode(query or {}, doseq=True)
suffix = f"?{encoded}" if encoded else ""
return f"https://{self.ref.stack}/api/{API_VERSION}{path}{suffix}"
def request(
self,
method: str,
path: str,
query: dict[str, Any] | None = None,
body: dict[str, Any] | None = None,
accept: str = JSON_ACCEPT,
) -> tuple[bytes, str | None, str]:
payload = None if body is None else json.dumps(body).encode("utf-8")
request = urllib.request.Request(
self.url(path, query),
data=payload,
method=method,
headers={
"Accept": accept,
"Authorization": self.authorization,
"Content-Type": JSON_ACCEPT,
"User-Agent": USER_AGENT,
},
)
with urllib.request.urlopen(request, timeout=self.timeout, context=self.context) as response:
return response.read(), response.headers.get_content_type(), response.geturl()
def get_json(self, path: str, query: dict[str, Any] | None = None) -> Any:
raw, _, _ = self.request("GET", path, query)
return json.loads(raw.decode("utf-8"))
def credentials() -> tuple[str, str]:
access_key = os.environ.get("ONSHAPE_ACCESS_KEY") or getpass.getpass("Onshape access key: ")
secret_key = os.environ.get("ONSHAPE_SECRET_KEY") or getpass.getpass("Onshape secret key: ")
if not access_key or not secret_key:
raise ValueError("both Onshape access and secret keys are required")
return access_key, secret_key
def write_json(path: Path, value: Any) -> None:
path.write_text(json.dumps(value, ensure_ascii=True, indent=2) + "\n", encoding="utf-8")
def file_info(path: Path) -> tuple[int, str]:
digest = hashlib.sha256()
with path.open("rb") as stream:
for block in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(block)
return path.stat().st_size, digest.hexdigest()
def relative_path(path: Path, root: Path) -> str:
return path.relative_to(root).as_posix()
def save_json(
client: OnshapeClient,
root: Path,
name: str,
path: str,
filename: str,
query: dict[str, Any] | None = None,
) -> DownloadResult:
try:
value = client.get_json(path, query)
destination = root / filename
destination.parent.mkdir(parents=True, exist_ok=True)
write_json(destination, value)
size, digest = file_info(destination)
return DownloadResult(name, "downloaded", relative_path(destination, root), client.url(path, query), size, digest)
except urllib.error.HTTPError as exc:
return DownloadResult(name, "http_error", url=client.url(path, query), http_status=exc.code, message=exc.read().decode("utf-8", "replace")[:1000])
except (urllib.error.URLError, TimeoutError, OSError, ValueError) as exc:
return DownloadResult(name, "error", url=client.url(path, query), message=str(exc))
def save_binary(
client: OnshapeClient,
root: Path,
name: str,
path: str,
filename: str,
query: dict[str, Any] | None = None,
accept: str = "application/octet-stream",
) -> DownloadResult:
try:
payload, content_type, final_url = client.request("GET", path, query, accept=accept)
destination = root / filename
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_bytes(payload)
size, digest = file_info(destination)
return DownloadResult(name, "downloaded", relative_path(destination, root), final_url, size, digest, content_type=content_type)
except urllib.error.HTTPError as exc:
return DownloadResult(name, "http_error", url=client.url(path, query), http_status=exc.code, message=exc.read().decode("utf-8", "replace")[:1000])
except (urllib.error.URLError, TimeoutError, OSError) as exc:
return DownloadResult(name, "error", url=client.url(path, query), message=str(exc))
def part_studio_path(ref: PartStudioRef, suffix: str) -> str:
return f"/partstudios/d/{ref.did}/{ref.wvm}/{ref.wvmid}/e/{ref.eid}{suffix}"
def collect_sketch_artifacts(
client: OnshapeClient,
root: Path,
sketches: Any,
) -> list[DownloadResult]:
if not isinstance(sketches, list):
return []
results: list[DownloadResult] = []
seen_ids: set[str] = set()
for sketch in sketches:
if not isinstance(sketch, dict):
continue
sketch_id = sketch.get("sketchId") or sketch.get("featureId")
if not isinstance(sketch_id, str) or not sketch_id or sketch_id in seen_ids:
continue
seen_ids.add(sketch_id)
quoted_id = urllib.parse.quote(sketch_id, safe="")
base = part_studio_path(client.ref, f"/sketches/{quoted_id}")
results.append(
save_json(client, root, f"sketch/{sketch_id}/bounding_box", f"{base}/boundingboxes", f"sketches/{sketch_id}/bounding_box.json")
)
results.append(
save_json(client, root, f"sketch/{sketch_id}/tessellation", f"{base}/tessellatedentities", f"sketches/{sketch_id}/tessellated_entities.json")
)
return results
def export_step(client: OnshapeClient, root: Path, sample_id: str, poll_seconds: float, poll_limit: int) -> DownloadResult:
path = part_studio_path(client.ref, "/export/step")
body = {
"destinationName": f"{sample_id}.step",
"grouping": True,
"notifyUser": False,
"storeInDocument": False,
"triggerAutoDownload": False,
"stepUnit": "METER",
"stepVersionString": "AP242",
}
try:
raw, _, _ = client.request("POST", path, body=body)
translation = json.loads(raw.decode("utf-8"))
translation_id = translation.get("id")
if not isinstance(translation_id, str) or not translation_id:
return DownloadResult("step", "error", url=client.url(path), message="STEP export response has no translation id")
request_path = "/translations/" + urllib.parse.quote(translation_id, safe="")
for _ in range(poll_limit):
state = client.get_json(request_path)
request_state = state.get("requestState") if isinstance(state, dict) else None
if request_state == "DONE":
external_ids = state.get("resultExternalDataIds", [])
if not isinstance(external_ids, list) or not external_ids:
return DownloadResult("step", "error", url=client.url(request_path), message="completed STEP export has no external data id")
external_id = external_ids[0]
if not isinstance(external_id, str):
return DownloadResult("step", "error", url=client.url(request_path), message="invalid STEP external data id")
return save_binary(
client,
root,
"step",
f"/documents/d/{client.ref.did}/externaldata/{urllib.parse.quote(external_id, safe='')}",
"model.step",
accept="application/step, application/octet-stream",
)
if request_state == "FAILED":
return DownloadResult("step", "http_error", url=client.url(request_path), message=str(state.get("failureReason", "translation failed")))
time.sleep(poll_seconds)
return DownloadResult("step", "error", url=client.url(request_path), message=f"STEP translation did not finish after {poll_limit} polls")
except urllib.error.HTTPError as exc:
return DownloadResult("step", "http_error", url=client.url(path), http_status=exc.code, message=exc.read().decode("utf-8", "replace")[:1000])
except (urllib.error.URLError, TimeoutError, OSError, ValueError) as exc:
return DownloadResult("step", "error", url=client.url(path), message=str(exc))
def export_stl(client: OnshapeClient, root: Path) -> DownloadResult:
"""Export a Part Studio STL, retrying the two API-supported encodings.
Some older public documents reject one encoding even though the Part Studio
can otherwise be read. The retry is recorded in the URL and the manifest
retains a useful error if neither server-side export succeeds.
"""
path = part_studio_path(client.ref, "/stl")
attempts = (
{"mode": "binary", "grouping": "true", "units": "METER"},
{"mode": "text", "grouping": "true", "units": "METER"},
{"mode": "binary", "grouping": "false", "units": "METER"},
)
failures: list[str] = []
for query in attempts:
result = save_binary(client, root, "stl", path, "model.stl", query)
if result.status == "downloaded":
return result
detail = result.message or result.status
failures.append(f"{urllib.parse.urlencode(query)}: {detail}")
return DownloadResult("stl", "error", url=client.url(path), message=" | ".join(failures))
def save_shaded_preview(root: Path) -> DownloadResult:
"""Extract the API's base64 PNG view as a locally inspectable preview."""
shaded_path = root / "shaded_views.json"
try:
value = json.loads(shaded_path.read_text(encoding="utf-8"))
images = value.get("images", []) if isinstance(value, dict) else []
encoded = images[0] if isinstance(images, list) and images else None
if not isinstance(encoded, str):
return DownloadResult("shaded_preview", "error", message="shaded view response contains no PNG")
payload = base64.b64decode(encoded, validate=True)
if not payload.startswith(b"\x89PNG\r\n\x1a\n"):
return DownloadResult("shaded_preview", "error", message="shaded view response is not a PNG")
destination = root / "shaded_preview.png"
destination.write_bytes(payload)
size, digest = file_info(destination)
return DownloadResult("shaded_preview", "downloaded", relative_path(destination, root), "shaded_views.json#images[0]", size, digest, content_type="image/png")
except (OSError, ValueError, json.JSONDecodeError) as exc:
return DownloadResult("shaded_preview", "error", message=str(exc))
def complete_download(ref: PartStudioRef, authorization: str, output: Path, timeout: float, poll_seconds: float, poll_limit: int) -> dict[str, Any]:
root = output / ref.sample_id
root.mkdir(parents=True, exist_ok=True)
client = OnshapeClient(ref, authorization, timeout)
request_info = {
**asdict(ref),
"api_version": API_VERSION,
"download_contract": "all relevant data exposed by the public API for this Part Studio, not an internal Onshape document backup",
}
write_json(root / "request.json", request_info)
common = {"rollbackBarIndex": -1}
resources: list[tuple[str, str, str, dict[str, Any] | None]] = [
("document", f"/documents/{ref.did}", "document.json", None),
("workspaces", f"/documents/d/{ref.did}/workspaces", "workspaces.json", None),
("elements", f"/documents/d/{ref.did}/{ref.wvm}/{ref.wvmid}/elements", "elements.json", None),
("unit_info", f"/documents/d/{ref.did}/{ref.wvm}/{ref.wvmid}/unitinfo", "unit_info.json", None),
("configuration", f"/elements/d/{ref.did}/{ref.wvm}/{ref.wvmid}/e/{ref.eid}/configuration", "configuration.json", None),
("parts", f"/parts/d/{ref.did}/{ref.wvm}/{ref.wvmid}/e/{ref.eid}", "parts.json", {"withThumbnails": "true", "includeFlatParts": "true"}),
("features", part_studio_path(ref, "/features"), "features.json", {**common, "includeGeometryIds": "true", "noSketchGeometry": "false"}),
("featurescript_representation", part_studio_path(ref, "/featurescriptrepresentation"), "featurescript_representation.json", common),
("feature_specs", part_studio_path(ref, "/featurespecs"), "feature_specs.json", None),
("body_details", part_studio_path(ref, "/bodydetails"), "body_details.json", {**common, "includeSurfaces": "true", "includeCompositeParts": "true", "includeGeometricData": "true"}),
("bounding_boxes", part_studio_path(ref, "/boundingboxes"), "bounding_boxes.json", {"includeHidden": "true", "includeWireBodies": "true"}),
("mass_properties", part_studio_path(ref, "/massproperties"), "mass_properties.json", {**common, "massAsGroup": "true"}),
("sketches", part_studio_path(ref, "/sketches"), "sketches.json", {"includeGeometry": "true", "output3D": "true", "curvePoints": "true"}),
("named_views", f"/partstudios/d/{ref.did}/e/{ref.eid}/namedViews", "named_views.json", None),
("tessellated_faces", part_studio_path(ref, "/tessellatedfaces"), "tessellated_faces.json", {**common, "outputVertexNormals": "true", "outputFacetNormals": "true", "outputIndexTable": "true", "outputErrorFaces": "true"}),
("tessellated_edges", part_studio_path(ref, "/tessellatededges"), "tessellated_edges.json", common),
("shaded_views", part_studio_path(ref, "/shadedviews"), "shaded_views.json", {"viewMatrix": "front", "outputWidth": 512, "outputHeight": 512, "edges": "show", "showAllParts": "true", "includeSurfaces": "true", "useAntiAliasing": "true"}),
]
results = [save_json(client, root, *resource) for resource in resources]
sketches_result = next((item for item in results if item.name == "sketches" and item.status == "downloaded"), None)
if sketches_result and sketches_result.path:
sketches = json.loads((root / sketches_result.path).read_text(encoding="utf-8"))
results.extend(collect_sketch_artifacts(client, root, sketches))
results.extend(
[
save_binary(client, root, "parasolid", part_studio_path(ref, "/parasolid"), "model.x_t", {"version": "0", "includeExportIds": "true", "binaryExport": "false"}, "text/plain, application/octet-stream"),
export_stl(client, root),
save_binary(client, root, "gltf", part_studio_path(ref, "/gltf"), "model.gltf", {**common, "outputSeparateFaceNodes": "true", "outputFaceAppearances": "true"}, "model/gltf+json, model/gltf-binary, application/octet-stream"),
save_binary(client, root, "thumbnail", f"/thumbnails/d/{ref.did}/{ref.wvm}/{ref.wvmid}/e/{ref.eid}/s/512x512", "thumbnail.png", {"rejectEmpty": "true"}, "image/png, image/*, application/octet-stream"),
]
)
results.append(save_shaded_preview(root))
results.append(export_step(client, root, ref.sample_id, poll_seconds, poll_limit))
manifest = {
"schema": "onshape.complete_sample.v1",
"source": request_info,
"resource_count": len(results),
"downloaded_count": sum(item.status == "downloaded" for item in results),
"unavailable_count": sum(item.status != "downloaded" for item in results),
"resources": [asdict(item) for item in results],
}
write_json(root / "manifest.json", manifest)
return manifest
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--url", default=DEFAULT_SAMPLE_URL, help="Onshape Part Studio URL")
parser.add_argument("--id", default=DEFAULT_SAMPLE_ID, help="local sample identifier")
parser.add_argument("--out", type=Path, default=DEFAULT_OUTPUT, help="output root")
parser.add_argument("--timeout", type=float, default=60.0, help="per-request timeout in seconds")
parser.add_argument("--poll-seconds", type=float, default=3.0, help="STEP translation poll interval")
parser.add_argument("--poll-limit", type=int, default=40, help="maximum STEP translation polls")
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
if args.poll_seconds <= 0 or args.poll_limit < 1:
raise ValueError("poll interval must be positive and poll limit must be at least 1")
ref = parse_part_studio_url(args.id, args.url)
access_key, secret_key = credentials()
token = base64.b64encode(f"{access_key}:{secret_key}".encode("utf-8")).decode("ascii")
manifest = complete_download(ref, f"Basic {token}", args.out, args.timeout, args.poll_seconds, args.poll_limit)
print(json.dumps({key: manifest[key] for key in ("resource_count", "downloaded_count", "unavailable_count")}, indent=2))
return 0 if manifest["downloaded_count"] else 1
if __name__ == "__main__":
try:
raise SystemExit(main())
except (OSError, ValueError) as exc:
print(f"error: {exc}", file=sys.stderr)
raise SystemExit(2)
+243
View File
@@ -0,0 +1,243 @@
#!/usr/bin/env python3
"""Download a few raw Onshape feature-list responses for format inspection."""
from __future__ import annotations
import argparse
import base64
import getpass
import json
import os
import re
import ssl
import sys
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any
API_BASE = "https://cad.onshape.com"
DEFAULT_OUTPUT = Path("json_to_cdsl/input/onshape_api_samples")
DEFAULT_SAMPLES = (
(
"00000352",
"https://cad.onshape.com/documents/4185972a944744d8a7a0f2b4/"
"w/d82d7eef8edf4342b7e49732/e/b6d6b562e8b64e7ea50d8325",
),
(
"00000001",
"https://cad.onshape.com/documents/1ffb81a71e5b402e966b9341/"
"w/6e295017d1b34be684565c40/e/8df255ee6705423d8e85234e",
),
(
"00000002",
"https://cad.onshape.com/documents/1ffb81a71e5b402e966b9341/"
"w/6e295017d1b34be684565c40/e/bb398e4615fe4025b34ea8f0",
),
)
ONSHAPE_URL_RE = re.compile(
r"^https://(?P<stack>[^/]+)/documents/(?P<did>[^/]+)/"
r"(?P<wvm>w|v|m)/(?P<wvmid>[^/]+)/e/(?P<eid>[^/?#]+)"
)
@dataclass(frozen=True)
class PartStudioRef:
sample_id: str
source_url: str
stack: str
did: str
wvm: str
wvmid: str
eid: str
def parse_part_studio_url(sample_id: str, url: str) -> PartStudioRef:
match = ONSHAPE_URL_RE.match(url.strip())
if match is None:
raise ValueError(f"not an Onshape Part Studio URL: {url}")
return PartStudioRef(sample_id=sample_id, source_url=url.strip(), **match.groupdict())
def read_url_file(path: Path) -> list[tuple[str, str]]:
"""Read either ABC objects YAML lines or plain '<id> <url>' lines."""
records: list[tuple[str, str]] = []
url_pattern = re.compile(r"https://cad\.onshape\.com/documents/[^'\"\s]+")
id_pattern = re.compile(r"^\s*['\"]?(\d+)['\"]?\s*[:\s]")
for line_number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
url_match = url_pattern.search(line)
if url_match is None:
continue
id_match = id_pattern.search(line)
if id_match is None:
raise ValueError(f"{path}:{line_number}: URL has no leading sample ID")
records.append((id_match.group(1).zfill(8), url_match.group(0)))
if not records:
raise ValueError(f"no Onshape Part Studio URLs found in {path}")
return records
def credentials() -> tuple[str, str]:
access_key = os.environ.get("ONSHAPE_ACCESS_KEY")
secret_key = os.environ.get("ONSHAPE_SECRET_KEY")
if not access_key:
access_key = getpass.getpass("Onshape access key: ")
if not secret_key:
secret_key = getpass.getpass("Onshape secret key: ")
if not access_key or not secret_key:
raise ValueError("both Onshape access and secret keys are required")
return access_key, secret_key
def api_url(ref: PartStudioRef) -> str:
query = urllib.parse.urlencode(
{
"rollbackBarIndex": -1,
"includeGeometryIds": "true",
"noSketchGeometry": "false",
}
)
return (
f"https://{ref.stack}/api/v9/partstudios/d/{ref.did}/"
f"{ref.wvm}/{ref.wvmid}/e/{ref.eid}/features?{query}"
)
def fetch_json(url: str, authorization: str, timeout: float) -> Any:
request = urllib.request.Request(
url,
headers={
"Accept": "application/json;charset=UTF-8; qs=0.09",
"Authorization": authorization,
"User-Agent": "cdsl-cad-onshape-sample-downloader/1.0",
},
)
context = ssl.create_default_context(cafile=_ca_bundle())
with urllib.request.urlopen(request, timeout=timeout, context=context) as response:
return json.load(response)
def _ca_bundle() -> str | None:
try:
import certifi
except ImportError:
return None
return certifi.where()
def write_json(path: Path, value: Any) -> None:
path.write_text(json.dumps(value, ensure_ascii=True, indent=2) + "\n", encoding="utf-8")
def download_one(
ref: PartStudioRef,
authorization: str,
output_dir: Path,
timeout: float,
) -> dict[str, Any]:
sample_dir = output_dir / ref.sample_id
sample_dir.mkdir(parents=True, exist_ok=True)
request_url = api_url(ref)
metadata = {
**asdict(ref),
"api_version": "v9",
"features_url": request_url,
}
write_json(sample_dir / "request.json", metadata)
try:
payload = fetch_json(request_url, authorization, timeout)
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace")
return {
"sample_id": ref.sample_id,
"status": "http_error",
"http_status": exc.code,
"message": body[:1000],
}
except (urllib.error.URLError, TimeoutError) as exc:
return {
"sample_id": ref.sample_id,
"status": "network_error",
"message": str(exc),
}
output_path = sample_dir / "features.json"
write_json(output_path, payload)
features = payload.get("features", []) if isinstance(payload, dict) else []
feature_types: dict[str, int] = {}
for feature in features:
if not isinstance(feature, dict):
continue
feature_type = str(feature.get("featureType", "unknown"))
feature_types[feature_type] = feature_types.get(feature_type, 0) + 1
return {
"sample_id": ref.sample_id,
"status": "downloaded",
"output": str(output_path),
"feature_count": len(features),
"feature_types": feature_types,
}
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--url",
action="append",
default=[],
metavar="ID=URL",
help="Onshape Part Studio URL with a stable local sample ID; repeatable",
)
parser.add_argument(
"--url-file",
type=Path,
help="ABC objects YAML or plain text file containing '<id> <url>' records",
)
parser.add_argument("--count", type=int, default=3, help="maximum samples to download")
parser.add_argument("--out", type=Path, default=DEFAULT_OUTPUT, help="output directory")
parser.add_argument("--timeout", type=float, default=60.0, help="request timeout in seconds")
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
if args.count < 1:
raise ValueError("--count must be at least 1")
records: list[tuple[str, str]] = []
for value in args.url:
if "=" not in value:
raise ValueError("--url must use ID=URL syntax")
records.append(tuple(value.split("=", 1)))
if args.url_file:
records.extend(read_url_file(args.url_file))
if not records:
records.extend(DEFAULT_SAMPLES)
refs = [parse_part_studio_url(sample_id, url) for sample_id, url in records[: args.count]]
access_key, secret_key = credentials()
token = base64.b64encode(f"{access_key}:{secret_key}".encode("utf-8")).decode("ascii")
authorization = f"Basic {token}"
args.out.mkdir(parents=True, exist_ok=True)
results = [download_one(ref, authorization, args.out, args.timeout) for ref in refs]
manifest = {
"schema": "onshape.api.samples.v1",
"api_base": API_BASE,
"sample_count": len(results),
"results": results,
}
write_json(args.out / "manifest.json", manifest)
print(json.dumps(manifest, ensure_ascii=True, indent=2))
return 0 if all(item["status"] == "downloaded" for item in results) else 1
if __name__ == "__main__":
try:
raise SystemExit(main())
except (OSError, ValueError) as exc:
print(f"error: {exc}", file=sys.stderr)
raise SystemExit(2)