openspec
Official Python client for the OpenSpec Index API: searchable specifications for manufacturer parts, with the source URL on every record.
No key needed for the free tier. No runtime dependencies.
pip install openspec
On Debian, Ubuntu or Mint that will stop with error: externally-managed-environment.
That is PEP 668 protecting the system Python, not a
problem with this package. Use a virtual environment, which is what you want for a library
you are importing anyway:
python3 -m venv ~/openspec-venv
~/openspec-venv/bin/pip install openspec
~/openspec-venv/bin/python your_script.py
pipx is the wrong tool here: it installs command line applications, and this is a library
you import. --break-system-packages works and is exactly as advisable as it sounds.
Why this exists
Product catalogs are organized by part number, which works when you already know the part and fails when you do not. Engineers and purchasers usually know the specifications they need without knowing who makes something that meets them. OpenSpec searches the other direction.
Every record carries the page it was read from and the date it was read, so any number can be traced back and checked.
One call
from openspec import OpenSpec
os = OpenSpec()
for part in os.find("316 stainless nylon insert lock nut"):
print(part.mpn, part.manufacturer.name, part.source_url)
Every record carries where it came from and when it was last read:
part = next(iter(os.find("1/4-20 stainless hex nut")))
print(part.source.url) # https://www.albanycountyfasteners.com/...
print(part.source.checked_at) # 2026-07-05T05:07:21.077Z
print(part.price_each) # 0.1
print(part.assets_of("cadFiles"))
find() takes plain words and resolves them against the real category tree. It also tells
you which words it could not apply:
result = os.find("cheap 4 inch grooved carbon steel tee")
print(result.total) # 9
print(result.family) # tee
print(result.ignored_words) # ('cheap', 'carbon')
Show ignored_words to your user. A query that quietly dropped half of what was asked looks
identical to one that answered it.
Filtering by specification
page = os.parts(family="tee", material_class="steel", run_size=4)
print(page.total)
for part in page:
print(part.mpn, part.spec("end_run_connection_type"))
Ratings compare as thresholds, not equalities
This is the one thing worth reading twice. An attribute whose kind is atLeast filters with
>=, so asking for 175 psi also returns everything rated higher. There is no operator to
write, because the direction belongs to the attribute rather than to your query.
os.count(family="elbow", pressure_working_max_psi=175) # 8051
os.count(family="elbow", pressure_working_max_psi=1000) # 2163
os.count(family="elbow", pressure_working_max_psi=5000) # 338
Ask for what your application needs and better parts still qualify. To check which behaviour an attribute uses:
attr = os.category("elbow").attribute("pressure_working_max_psi")
print(attr.kind) # atLeast
print(attr.is_threshold) # True
print(attr.matches) # "Pass a number. Matches parts rated AT LEAST that number..."
A class is not a pressure. pressure_class is an ASME designation, and a class is a
curve against temperature: a Class 150 flange is rated 285 psi at 100F and 75 psi at 800F.
Use it to identify a part, never to answer whether one is good for a given pressure.
Portable attribute names
Families spell the same concept differently. A tee has run_size, the reducer beside it has
size_large_in, the elbow has size1_in. Portable names resolve to whichever the family
uses, so one query shape covers many families:
for family in ("tee", "elbow", "reducer", "nipple", "coupling"):
print(family, os.count(family=family, size_in=4, material_class="steel"))
Available everywhere: size_in, size2_in, material_class, material_grade, finish,
connection_type, connection_type2, schedule, thread, pressure_class,
pressure_max_psi, temp_max_f, temp_min_f.
The family's own names keep working. To see where a portable name lands:
os.category("reducer").resolve("size_in") # 'size_large_in'
Exploring a category
count(), categories() and values() are free and unlimited, so explore with those
before spending a row pull.
cat = os.category("valves")
print([a.attr for a in cat.gates]) # the identity attributes
print([t.type for t in cat.part_types][:5])
vals = os.values("valves", "connection_type") # every value, per type, with counts
print(vals["matches"])
Add one filter at a time and watch the count. When it drops to zero you know exactly which filter did it. That is the whole debugging technique.
Paging
for part in os.iter_parts(family="screw", thread_spec="1/4-20", max_parts=500):
...
Stops on the first empty page, on max_parts, or when the free tier is exhausted.
Cross-reference
matches = os.equivalents("SS-AFSF12")
Read specsCompared and specsStated on each result before trusting a match percentage.
100% agreement across one shared spec is not the same claim as 100% across twelve, and both
numbers are returned so the difference stays visible.
Errors
The API refuses on purpose in cases where an empty result would be a wrong answer, and its error messages carry the legal values. This client keeps that text verbatim.
from openspec import BadRequest
try:
os.parts(family="tee", material_class="carbon-steel")
except BadRequest as e:
print(e)
# unknown value "carbon-steel" for attr.material_class in family "tee".
# Valid values: pvc, cast-iron, stainless-steel, copper, steel, brass, ...
Exceptions: BadRequest (400), NotFound (404), RateLimited (429), ServerError (5xx),
all subclasses of OpenSpecError.
Rate limits
Every IP gets 100 free data queries. Counts, categories and values are free and unlimited.
Past the limit the API degrades rather than blocking: it answers with a match count and
no rows, which this client surfaces as Page.limited rather than raising. That is a real
answer, not a failure.
page = os.parts(family="nut")
if page.limited:
print(f"{page.total} matches, but out of free row pulls")
Iterating a limited result raises RateLimited rather than yielding nothing. A count
with no rows is an honest answer to a question the API declined to fully answer, and
handing that back as an empty loop would turn it into a silent zero, which reads as "no
such parts exist". .total, .limited and len() never raise, so you can always check
first.
For a key, email knightc@openspecindex.com. Pass it as
OpenSpec(api_key=...) or set OPENSPEC_API_KEY. It travels as a header, never in the URL.
Using this with an LLM
Everything returned carries source_url. Cite it. An answer without its source is the exact
failure this index exists to remove, and a remembered part number is worse than no answer.
If you are wiring this into an agent, find() is the endpoint to expose: one call, plain
words, and it reports what it ignored.
Development
git clone https://github.com/openspecindex/openspec-python
cd openspec-python
PYTHONPATH=src python3 -m unittest discover -s tests
OPENSPEC_LIVE=1 PYTHONPATH=src python3 -m unittest discover -s tests # hits the real API
License
MIT
Metadata
Release files for openspec 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| openspec-0.1.0.tar.gz | 18.3 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| openspec-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 34.4 kB
Release files / openspec-0.1.0.tar.gz
| Download URL | openspec-0.1.0.tar.gz |
|---|---|
| Size | 18.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
04eefe84dbe10d04ad984204ba83a12b949842043051a7e5fd933707733c1a52
|
|
BLAKE2b-256 checksum How to use checksums |
78f7bd5a21c6f6b5aa4c20570e1a13d20f77a12eacb687bb63916fef7302aede
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.3
|
Release files / openspec-0.1.0-py3-none-any.whl
| Download URL | openspec-0.1.0-py3-none-any.whl |
|---|---|
| Size | 16.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
382b79c7b5c0cca46ba8d9bcd2b2e747f8938fefea659ef8160a9fde8e0ffb5c
|
|
BLAKE2b-256 checksum How to use checksums |
aa434d233fbebb1c831776d978e132dacc766fc32ca97cb0f6291b20bd98004e
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.3
|