Metadata-Version: 2.5
Name: openspec
Version: 0.1.0
Summary: Official Python client for the OpenSpec Index API: searchable specifications for manufacturer parts, with a source URL on every record.
Project-URL: Homepage, https://openspecindex.com
Project-URL: Documentation, https://openspecindex.com/docs
Project-URL: API, https://api.openspecindex.com
Project-URL: Source, https://github.com/openspecindex/openspec-python
Project-URL: Issues, https://github.com/openspecindex/openspec-python/issues
Author-email: KnightDevs <knightc@openspecindex.com>
License: MIT
License-File: LICENSE
Keywords: api-client,bill-of-materials,bom,cross-reference,engineering,industrial,manufacturing,parts,procurement,specifications
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: Manufacturing
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Scientific/Engineering
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.9
Provides-Extra: dev
Requires-Dist: pytest>=7.0; extra == 'dev'
Description-Content-Type: text/markdown

# openspec

Official Python client for the [OpenSpec Index](https://openspecindex.com) API: searchable
specifications for manufacturer parts, with the source URL on every record.

No key needed for the free tier. No runtime dependencies.

```bash
pip install openspec
```

On Debian, Ubuntu or Mint that will stop with `error: externally-managed-environment`.
That is [PEP 668](https://peps.python.org/pep-0668/) 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:

```bash
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

```python
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:

```python
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:

```python
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

```python
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.

```python
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:

```python
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:

```python
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:

```python
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.

```python
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

```python
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

```python
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.

```python
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.

```python
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](mailto: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

```bash
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
