Import upstream snapshot 80e710700aac9573a2230f74f7ce9e094833a0bc

Upstream: https://github.com/Rhoban/onshape-to-robot
Upstream-Commit: 80e710700aac9573a2230f74f7ce9e094833a0bc
Upstream-Branch: master
This commit is contained in:
Upstream Snapshot
2026-08-28 15:42:31 +08:00
commit bb87928bce
107 changed files with 16958 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
'''
onshape_api, based on onshape "apikey"
======
Demonstrates usage of API keys for the Onshape REST API
'''
__copyright__ = 'Copyright (c) 2016 Onshape, Inc.'
__license__ = 'All rights reserved.'
__title__ = 'onshape_api'
__all__ = ['onshape', 'client', 'utils']
+54
View File
@@ -0,0 +1,54 @@
import os
import inspect
import hashlib
from pathlib import Path
import pickle
def get_cache_path() -> Path:
"""
Return the path to the user cache.
"""
path = Path.home() / ".cache" / "onshape-to-robot"
path.mkdir(parents=True, exist_ok=True)
return path
def can_cache(method, *args, **kwargs) -> bool:
"""
Check if the cache can be used.
When using wmv=w, the current workspace is used, which make it impossible to cache.
"""
signature = inspect.signature(method)
wmv = None
if "wmv" in signature.parameters:
wmv = signature.parameters["wmv"].default
if "wmv" in kwargs:
wmv = kwargs["wmv"]
return wmv != "w"
def cache_response(method):
"""
Decorator that caches the response of a method.
"""
def cached_call(*args, **kwargs):
# Checking if the method can be cached
if not can_cache(method, *args, **kwargs):
return method(*args, **kwargs)
# Building filename that is unique for method/args combination
method_name = method.__qualname__
arguments = {"args": args, "kwargs": kwargs}
arguments_hash = hashlib.sha1(pickle.dumps(arguments)).hexdigest()
filename = f"{get_cache_path()}/{method_name}_{arguments_hash}.pkl"
if not os.path.exists(filename):
result = method(*args, **kwargs)
with open(filename, "wb") as f:
pickle.dump(result, f)
return pickle.load(open(filename, "rb"))
return cached_call
+254
View File
@@ -0,0 +1,254 @@
"""
client
======
Convenience functions for working with the Onshape API
"""
from .onshape import Onshape
from .cache import cache_response
def escape(s):
return s.replace("/", "%2f").replace("+", "%2b")
class Client:
"""
Defines methods for testing the Onshape API. Comes with several methods:
- Create a document
- Delete a document
- Get a list of documents
Attributes:
- stack (str, default='https://cad.onshape.com'): Base URL
- logging (bool, default=True): Turn logging on or off
"""
def __init__(
self, stack="https://cad.onshape.com", logging=True, creds="./config.json"
):
"""
Instantiates a new Onshape client.
Args:
- stack (str, default='https://cad.onshape.com'): Base URL
- logging (bool, default=True): Turn logging on or off
"""
self._metadata_cache = {}
self._massproperties_cache = {}
self._stack = stack
self._api = Onshape(stack=stack, logging=logging, creds=creds)
def request(self, url, **kwargs):
return self._api.request("get", url, **kwargs).json()
def request_binary(self, url, **kwargs):
return self._api.request("get", url, **kwargs).content
@cache_response
def get_document(self, did):
"""
Get details for a specified document.
Args:
- did (str): Document ID
Returns:
- requests.Response: Onshape response data
"""
return self.request(f"/api/documents/{escape(did)}")
@cache_response
def list_elements(self, did, wid, wmv="w"):
"""
Get the list of elements in a given document
"""
return self.request(
f"/api/documents/d/{escape(did)}/{escape(wmv)}/{escape(wid)}/elements"
)
@cache_response
def get_assembly(self, did, wmvid, eid, wmv="w", configuration="default"):
"""
Retrieve the assembly structure for a specified document / workspace / element.
"""
return self.request(
f"/api/assemblies/d/{escape(did)}/{escape(wmv)}/{escape(wmvid)}/e/{escape(eid)}",
query={
"includeMateFeatures": "true",
"includeMateConnectors": "true",
"includeNonSolids": "true",
"configuration": configuration,
},
)
@cache_response
def get_features(self, did, wvid, eid, wmv="w", configuration="default"):
"""
Gets the feature list for specified document / workspace / part studio.
Args:
- did (str): Document ID
- mid (str): Microversion
- eid (str): Element ID
Returns:
- requests.Response: Onshape response data
"""
return self.request(
f"/api/assemblies/d/{escape(did)}/{escape(wmv)}/{escape(wvid)}/e/{escape(eid)}/features",
query={"configuration": configuration},
)
@cache_response
def get_sketches(self, did, mid, eid, configuration):
"""
Get sketches for a given document / microversion / element.
"""
return self.request(
f"/api/partstudios/d/{escape(did)}/m/{escape(mid)}/e/{escape(eid)}/sketches",
query={"includeGeometry": "true", "configuration": configuration},
)
@cache_response
def get_parts(self, did, mid, eid, configuration):
"""
Get parts for a given document / microversion / element.
"""
return self.request(
f"/api/parts/d/{escape(did)}/m/{escape(mid)}/e/{escape(eid)}",
query={"configuration": configuration},
)
def find_new_partid(
self, did, mid, eid, partid, configuration_before, configuration
):
before = self.get_parts(did, mid, eid, configuration_before)
name = None
for entry in before:
if entry["partId"] == partid:
name = entry["name"]
if name is not None:
after = self.get_parts(did, mid, eid, configuration)
for entry in after:
if entry["name"] == name:
return entry["partId"]
else:
print("Onshape ERROR: Can't find new partid for " + str(partid))
return partid
@cache_response
def part_studio_stl_m(
self,
did,
wmvid,
eid,
partid="",
wmv="m",
configuration="default",
linked_document_id=None,
):
req_headers = {"Accept": "*/*"}
query = {
"mode": "binary",
"units": "meter",
"configuration": configuration,
}
if linked_document_id is not None:
query["linkDocumentId"] = linked_document_id
return self.request_binary(
f"/api/parts/d/{escape(did)}/{escape(wmv)}/{escape(wmvid)}/e/{escape(eid)}/partid/{escape(partid)}/stl",
query=query,
headers=req_headers,
)
@cache_response
def matevalues(self, did, wmvid, eid, wmv="w", configuration="default"):
return self.request(
f"/api/assemblies/d/{escape(did)}/{wmv}/{escape(wmvid)}/e/{escape(eid)}/matevalues",
query={"configuration": configuration},
)
@cache_response
def part_get_metadata(
self,
did,
wmvid,
eid,
partid,
wmv="m",
configuration="default",
linked_document_id=None,
):
query = {"configuration": configuration}
if linked_document_id is not None:
query["linkDocumentId"] = linked_document_id
return self.request(
f"/api/metadata/d/{escape(did)}/{escape(wmv)}/{escape(wmvid)}/e/{escape(eid)}/p/{escape(partid)}",
query=query,
)
@cache_response
def part_mass_properties(
self,
did,
wmvid,
eid,
partid,
wmv="m",
configuration="default",
linked_document_id=None,
):
query = {
"configuration": configuration,
"useMassPropertyOverrides": True,
}
if linked_document_id is not None:
query["linkDocumentId"] = linked_document_id
return self.request(
f"/api/parts/d/{escape(did)}/{escape(wmv)}/{escape(wmvid)}/e/{escape(eid)}/partid/{escape(partid)}/massproperties",
query=query,
)
@cache_response
def standard_cont_mass_properties(
self, did, vid, eid, partid, linked_document_id, configuration
):
return self.request(
f"/api/parts/d/{escape(did)}/v/{escape(vid)}/e/{escape(eid)}/partid/{escape(partid)}/massproperties",
query={
"configuration": configuration,
"useMassPropertyOverrides": True,
"linkDocumentId": linked_document_id,
"inferMetadataOwner": True,
},
)
@cache_response
def elements_configuration(
self, did, wmvid, eid, wmv, linked_document_id=None, configuration=None
):
query = {}
if linked_document_id is not None:
query["linkDocumentId"] = linked_document_id
return self.request(
f"/api/elements/d/{escape(did)}/{escape(wmv)}/{escape(wmvid)}/e/{escape(eid)}/configuration",
query=query,
)
@cache_response
def get_variables(self, did, wvid, eid, wmv, configuration):
return self.request(
f"/api/variables/d/{escape(did)}/{escape(wmv)}/{escape(wvid)}/e/{escape(eid)}/variables",
query={
"configuration": configuration,
"includeValuesAndReferencedVariables": True,
},
)
+247
View File
@@ -0,0 +1,247 @@
'''
onshape
======
Provides access to the Onshape REST API
'''
from . import utils
import os
import random
import string
import commentjson as json
import hmac
import hashlib
import base64
import urllib
import datetime
import requests
from colorama import Fore, Back, Style
from urllib.parse import urlparse
from urllib.parse import parse_qs
__all__ = [
'Onshape'
]
class Onshape():
'''
Provides access to the Onshape REST API.
Attributes:
- stack (str): Base URL
- creds (str, default='./creds.json'): Credentials location
- logging (bool, default=True): Turn logging on or off
'''
def __init__(self, stack, creds='./config.json', logging=True):
'''
Instantiates an instance of the Onshape class. Reads credentials from a JSON file
of this format:
{
"http://cad.onshape.com": {
"access_key": "YOUR KEY HERE",
"secret_key": "YOUR KEY HERE"
},
etc... add new object for each stack to test on
}
The creds.json file should be stored in the root project folder; optionally,
you can specify the location of a different file.
Args:
- stack (str): Base URL
- creds (str, default='./config.json'): Credentials location
'''
if not os.path.isfile(creds):
raise IOError('%s is not a file' % creds)
self._logging = logging
with open(creds, "r", encoding="utf-8") as stream:
try:
config = json.load(stream)
except TypeError as ex:
raise ValueError('%s is not valid json' % creds) from ex
try:
# Trying to retrieve from config.json, this is deprecated but kept for backwards compatibility
self._url = config["onshape_api"]
self._access_key = config['onshape_access_key'].encode('utf-8')
self._secret_key = config['onshape_secret_key'].encode('utf-8')
print(Fore.YELLOW + 'WARNING: Storing Onshape credentials in config.json is deprecated, please use environment variables instead' + Style.RESET_ALL)
except KeyError:
self._url = os.getenv('ONSHAPE_API')
self._secret_bearer = os.getenv('ONSHAPE_SECRET_BEARER')
self._access_key = os.getenv('ONSHAPE_ACCESS_KEY')
self._secret_key = os.getenv('ONSHAPE_SECRET_KEY')
if self._url and self._secret_bearer:
self._secret_bearer = self._secret_bearer.encode('utf-8')
elif self._url and self._access_key and self._secret_key:
self._access_key = self._access_key.encode('utf-8')
self._secret_key = self._secret_key.encode('utf-8')
else:
print(Fore.RED + 'ERROR: No Onshape API access key are set' + Style.RESET_ALL)
print()
print(Fore.BLUE + 'TIP: Connect to https://dev-portal.onshape.com/keys, and edit your .bashrc file:' + Style.RESET_ALL)
print(Fore.BLUE + 'export ONSHAPE_API=https://cad.onshape.com' + Style.RESET_ALL)
print(Fore.BLUE + 'export ONSHAPE_ACCESS_KEY=Your_Access_Key' + Style.RESET_ALL)
print(Fore.BLUE + 'export ONSHAPE_SECRET_KEY=Your_Secret_Key' + Style.RESET_ALL)
exit(1)
if self._logging:
utils.log('onshape instance created: url = %s, access key = %s' % (self._url, self._access_key))
def _make_nonce(self):
'''
Generate a unique ID for the request, 25 chars in length
Returns:
- str: Cryptographic nonce
'''
chars = string.digits + string.ascii_letters
nonce = ''.join(random.choice(chars) for i in range(25))
if self._logging:
utils.log('nonce created: %s' % nonce)
return nonce
def _append_auth(self, output_headers: dict, method, date, path, query={}, ctype='application/json'):
'''
Create the request signature to authenticate
Args:
- method (str): HTTP method
- date (str): HTTP date header string
- nonce (str): Cryptographic nonce
- path (str): URL pathname
- query (dict, default={}): URL query string in key-value pairs
- ctype (str, default='application/json'): HTTP Content-Type
'''
if self._secret_bearer:
# Access using simple bearer token
output_headers['Authorization'] = 'Bearer ' + self._secret_bearer.decode('utf-8')
else:
# Access using API key
nonce = self._make_nonce()
query = urllib.parse.urlencode(query)
hmac_str = (method + '\n' + nonce + '\n' + date + '\n' + ctype + '\n' + path +
'\n' + query + '\n').lower().encode('utf-8')
signature = base64.b64encode(hmac.new(self._secret_key, hmac_str, digestmod=hashlib.sha256).digest())
auth = 'On ' + self._access_key.decode('utf-8') + ':HmacSHA256:' + signature.decode('utf-8')
if self._logging:
utils.log({
'query': query,
'hmac_str': hmac_str,
'signature': signature,
'auth': auth
})
output_headers['On-Nonce'] = nonce
output_headers['Authorization'] = auth
def _make_headers(self, method, path, query={}, headers={}):
'''
Creates a headers object to sign the request
Args:
- method (str): HTTP method
- path (str): Request path, e.g. /api/documents. No query string
- query (dict, default={}): Query string in key-value format
- headers (dict, default={}): Other headers to pass in
Returns:
- dict: Dictionary containing all headers
'''
date = datetime.datetime.utcnow().strftime('%a, %d %b %Y %H:%M:%S GMT')
ctype = headers.get('Content-Type') if headers.get('Content-Type') else 'application/json'
req_headers = {
'Content-Type': 'application/json',
'Date': date,
'User-Agent': 'Onshape Python Sample App',
'Accept': 'application/json'
}
self._append_auth(req_headers, method, date, path, query=query, ctype=ctype)
# add in user-defined headers
for h in headers:
req_headers[h] = headers[h]
return req_headers
def request(self, method, path, query={}, headers={}, body={}, base_url=None):
'''
Issues a request to Onshape
Args:
- method (str): HTTP method
- path (str): Path e.g. /api/documents/:id
- query (dict, default={}): Query params in key-value pairs
- headers (dict, default={}): Key-value pairs of headers
- body (dict, default={}): Body for POST request
- base_url (str, default=None): Host, including scheme and port (if different from creds file)
Returns:
- requests.Response: Object containing the response from Onshape
'''
req_headers = self._make_headers(method, path, query, headers)
if base_url is None:
base_url = self._url
url = base_url + path + '?' + urllib.parse.urlencode(query)
if self._logging:
utils.log(body)
utils.log(req_headers)
utils.log('request url: ' + url)
# only parse as json string if we have to
body = json.dumps(body) if type(body) == dict else body
res = requests.request(method, url, headers=req_headers, data=body, allow_redirects=False, stream=True)
if res.status_code == 307:
location = urlparse(res.headers["Location"])
querystring = parse_qs(location.query)
if self._logging:
utils.log('request redirected to: ' + location.geturl())
new_query = {}
new_base_url = location.scheme + '://' + location.netloc
for key in querystring:
new_query[key] = querystring[key][0] # won't work for repeated query params
return self.request(method, location.path, query=new_query, headers=headers, base_url=new_base_url)
elif not 200 <= res.status_code <= 206:
print(url)
print('! ERROR ('+str(res.status_code)+') while using Onshape API')
if res.text:
print('! '+res.text)
if res.status_code == 403:
print('HINT: Check that your access rights are correct, and that the clock on your computer is set correctly')
exit()
if self._logging:
utils.log('request failed, details: ' + res.text, level=1)
else:
if self._logging:
utils.log('request succeeded, details: ' + res.text)
return res
+74
View File
@@ -0,0 +1,74 @@
'''
utils
=====
Handy functions for API key sample app
'''
import logging
from logging.config import dictConfig
__all__ = [
'log'
]
def log(msg, level=0):
'''
Logs a message to the console, with optional level paramater
Args:
- msg (str): message to send to console
- level (int): log level; 0 for info, 1 for error (default = 0)
'''
red = '\033[91m'
endc = '\033[0m'
# configure the logging module
cfg = {
'version': 1,
'disable_existing_loggers': False,
'formatters': {
'stdout': {
'format': '[%(levelname)s]: %(asctime)s - %(message)s',
'datefmt': '%x %X'
},
'stderr': {
'format': red + '[%(levelname)s]: %(asctime)s - %(message)s' + endc,
'datefmt': '%x %X'
}
},
'handlers': {
'stdout': {
'class': 'logging.StreamHandler',
'level': 'DEBUG',
'formatter': 'stdout'
},
'stderr': {
'class': 'logging.StreamHandler',
'level': 'ERROR',
'formatter': 'stderr'
}
},
'loggers': {
'info': {
'handlers': ['stdout'],
'level': 'INFO',
'propagate': True
},
'error': {
'handlers': ['stderr'],
'level': 'ERROR',
'propagate': False
}
}
}
dictConfig(cfg)
lg = 'info' if level == 0 else 'error'
lvl = 20 if level == 0 else 40
logger = logging.getLogger(lg)
logger.log(lvl, msg)