gunspec
Official Python SDK for the GunSpec.io firearms specification database API. Sync and async clients over httpx, typed parameters, pydantic models, retries with backoff, conditional requests, and webhook verification. Python 3.9 or later.
Installation
pip install gunspec
Quick Start
from gunspec import GunSpec
# Reads GUNSPEC_API_KEY from the environment
client = GunSpec()
# List firearms; data rows are dicts as the API returns them
result = client.firearms.list({"category": "pistol", "per_page": 5})
for firearm in result.data:
print(firearm["id"], firearm["name"])
# Get a single firearm (Builder plan)
detail = client.firearms.get("glock-g19").data
print(detail["name"], detail["version"], detail["updatedAt"])
Async Usage
from gunspec import AsyncGunSpec
async with AsyncGunSpec() as client:
result = await client.firearms.list({"category": "rifle"})
for firearm in result.data:
print(firearm["name"])
Configuration
import os
from gunspec import GunSpec, RetryConfig
client = GunSpec(
api_key=os.environ["GUNSPEC_API_KEY"], # omitted reads GUNSPEC_API_KEY; None is anonymous on purpose
auth_scheme="x-api-key", # or "bearer" for Authorization: Bearer
base_url="https://api.gunspec.io", # default; a key over plain http is refused unless allow_insecure=True
timeout=30.0, # seconds
retry=RetryConfig(
max_retries=2,
initial_delay_s=0.5,
max_retry_after_s=30.0, # a longer server wait is raised, not slept through
),
etag_cache=True, # hold ETags; a 304 is served from cache and skips the daily cap
)
print(client.is_authenticated, repr(client)) # the key is masked in repr
An explicit api_key=None sends no credential and does not read the environment, which is how a test or a public-only caller opts out of a key the shell exports.
Resources
Every method takes the matching TypedDict from gunspec.types.params (a plain dict works too) and returns APIResponse[Dict[str, Any]] or PaginatedResponse[Dict[str, Any]]: .data, .status, .headers, .request_id, .rate_limit, .etag, .cache_control, .from_cache, and .pagination (page, limit, per_page, total, total_pages) on lists.
| Resource | Methods |
|---|---|
client.firearms |
list, get, search, compare, resolve, resolve_many, game_meta, action_types, filter_options, random, top, head_to_head, by_feature, by_action, by_material, by_designer, power_rating, timeline, by_conflict, get_variants, get_images, get_game_stats, get_dimensions, get_users, get_family_tree, get_similar, get_adoption_map, get_game_profile, get_silhouette, get_schematics, popular, calculate, load, media_catalog, list_media, get_media, download_media, get_image_asset, get_model, get_offers, get_interfaces, get_attachments, list_auto_paging |
client.manufacturers |
list, get, get_firearms, get_timeline, get_stats, list_auto_paging |
client.calibers |
list, get, compare, ballistics, get_firearms, get_parent_chain, get_family, get_ammunition, list_auto_paging |
client.categories |
list, get_firearms |
client.stats |
summary, production_status, field_coverage, catalog_coverage, popular_calibers, prolific_manufacturers, by_category, by_era, materials, adoption_by_country, adoption_by_type, action_types, feature_frequency, caliber_popularity_by_era |
client.game |
balance_report, tier_list, matchups, role_roster, stat_distribution |
client.game_stats |
list_versions, list_firearms, get_firearm |
client.ammunition |
list, get, get_bullet_svg (text), ballistics, list_auto_paging |
client.countries |
list, get_arsenal |
client.conflicts |
list |
client.content |
list_changelog, get_changelog_entry, list_blog_posts, get_blog_post, list_notices (no key needed) |
client.collections |
get_shared |
client.attachments |
list, get, get_firearms, get_offers, list_auto_paging (fit computation is Studio) |
client.interfaces |
list, get_firearms |
client.platforms |
list, get (Studio) |
client.vendor |
shops, list_offers, push_offers, update_offer, delete_offer, click_url, resolve_click (Enterprise seller) |
client.data_quality |
coverage, confidence |
client.favorites |
list, list_ids, add, remove |
client.reports |
create, list |
client.support |
create, list, get, reply |
client.webhooks |
list, create, get, update, delete, test |
client.usage |
get |
client.http is the transport (get, get_paginated, get_text, get_bytes, request_conditional, resolve_redirect, url_for) for endpoints the resources do not cover yet.
Auto-Pagination
from gunspec import GunSpec
client = GunSpec()
count = 0
for firearm in client.firearms.list_auto_paging({"category": "rifle", "per_page": 100}):
count += 1
if count >= 150:
break
async for firearm in client.firearms.list_auto_paging({"category": "rifle"}):
print(firearm["name"])
Compatibility and sellers
from gunspec import GunSpec
client = GunSpec()
firearm_id = client.firearms.list({"per_page": 1}).data[0]["id"]
# What fits a firearm (Studio), with the evidence for each fit
fits = client.firearms.get_attachments(firearm_id, {"with_offers": True}).data
for group in fits["groups"]:
for item in group["items"]:
print(group["category"], item["name"], item["fitType"], item["confidence"])
# Where to buy: link through the tracked click so the seller sees the visit
for offer in client.firearms.get_offers(firearm_id, {"region": "AU"}).data:
href = client.vendor.click_url(offer["clickId"]) if offer["clickId"] else offer["url"]
# The mount vocabulary the fit engine reasons over
standards = client.interfaces.list({"kind": "thread"}).data
# Seller side (an Enterprise key named by a shop): read before you write
shops = client.vendor.shops().data
client.vendor.push_offers({"offers": [
{"sku": "A1", "firearm_id": "ak-74m", "price_cents": 129900, "currency": "AUD", "url": "https://shop.example/a1"},
]})
client.vendor.update_offer("A1", {"price_cents": 119900, "status": "published"})
client.vendor.delete_offer("A1")
Error Handling
Every APIError carries code (the family, never renamed) and reason (the specific situation, what to branch on), plus details, retry_after, request_id and action, the API's own one-line advice. str(err) reads NotFoundError(404 RESOURCE_NOT_FOUND): Firearm 'x' not found [request_id=...]; repr and to_dict() never include headers or the key.
from gunspec import (
APIError,
AuthenticationError,
GunSpec,
NotFoundError,
PermissionDeniedError,
RateLimitError,
)
client = GunSpec()
try:
client.firearms.get("no-such-firearm-xyz")
except NotFoundError as e:
print(e) # NotFoundError(404 RESOURCE_NOT_FOUND): ... [request_id=...]
print(e.action) # "Check the id. The list and search endpoints return valid ones."
except RateLimitError as e:
if e.is_daily_cap:
print("Daily allowance spent; resets at midnight UTC")
else:
print(f"Retry after: {e.retry_after}s")
except PermissionDeniedError as e:
if e.reason == "PLAN_REQUIRED":
print(f"Needs the {e.required_tier} plan")
except AuthenticationError as e:
print(e.reason, e.action) # e.g. KEY_EXPIRED, "Create a new key in your account."
except APIError as e:
log_line = e.to_dict() # JSON-serialisable, no headers, no key
ConflictError (409), PayloadTooLargeError (413, max_bytes) and ServiceUnavailableError (503, retry_after) cover those statuses; ConfigurationError, ConnectError, RequestTimeoutError and WebhookSignatureError share the same GunSpecError root. A spent daily cap is never retried; a Retry-After longer than max_retry_after_s is raised rather than slept through.
Conditional requests
from gunspec import GunSpec
client = GunSpec(etag_cache=True)
firearm_id = client.firearms.list({"per_page": 1}).data[0]["id"]
first = client.firearms.get(firearm_id) # 200, stored with its ETag
second = client.firearms.get(firearm_id) # 304 from the API, body from the cache
assert second.from_cache and second.data == first.data
# Holding your own tag
res = client.http.request_conditional(f"/v1/firearms/{firearm_id}", if_none_match=first.etag or "")
if res.not_modified:
pass
Pass a MemoryETagStore(max_entries) or your own ETagStore to etag_cache to size or persist it. Cache keys are namespaced by a fingerprint of the key, so a shared store never hands a paid-tier body to a cheaper key.
Verifying webhooks
import os
from gunspec import WebhookSignatureError, construct_webhook_event
@app.post("/hooks/gunspec")
def hook(request):
try:
event = construct_webhook_event(
request.get_data(), # the raw body, before parsing
request.headers.get("X-Webhook-Signature"),
os.environ["GUNSPEC_WEBHOOK_SECRET"],
)
except WebhookSignatureError:
return "", 400
if event["type"] == "firearm.updated":
mirror.upsert(event["data"])
return "", 204
Signatures are HMAC-SHA256 over f"{t}.{body}" and compared in constant time; deliveries older than tolerance_seconds (300) are refused. Dedupe on X-Webhook-Id; WEBHOOK_EVENT_TYPES lists every event. See examples/webhooks.py for a complete receiver.
Context Managers
from gunspec import GunSpec
with GunSpec() as client:
result = client.firearms.list({"per_page": 1})
async with AsyncGunSpec() as client:
result = await client.firearms.list()
Requirements
- Python >= 3.9 (the unit suite runs on 3.9; model fields use
Optional[...]so pydantic can build them there) - httpx >= 0.25
- pydantic >= 2
Development
cd packages/sdk-python
uv sync --extra dev
uv run pytest tests/unit # unit, no network
uv run mypy --strict src # type check
uv run ruff check src tests scripts examples # lint
uv run ruff format --check # formatting
uv run python scripts/sync_api_contracts.py # regenerate error reasons and webhook events from the API source
# Against the local API (see tests/integration/README.md)
GUNSPEC_INTEGRATION_BASE_URL=http://localhost:8788 GUNSPEC_INTEGRATION_API_KEY=... uv run pytest tests/integration
GUNSPEC_INTEGRATION_BASE_URL=http://localhost:8788 GUNSPEC_INTEGRATION_API_KEY=... uv run python scripts/smoke_readme.py
__version__ comes from the installed package metadata, so pyproject.toml is the only place the version lives (uv version 0.2.0b1 for a prerelease).
Publishing
cd packages/sdk-python
uv build # sdist and wheel in dist/, LICENSE and CHANGELOG.md included
uv publish # PyPI; UV_PUBLISH_TOKEN in the environment
License
MIT
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file gunspec-0.2.0.tar.gz.
File metadata
- Download URL: gunspec-0.2.0.tar.gz
- Upload date:
- Size: 85.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
uv/0.12.13 {"installer":{"name":"uv","version":"0.12.13","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d6d06b220829dcd14f7fcf4e17ec22080ec4c3c725f9c83ce42b90b7570173ca
|
|
| MD5 |
a3fed0fd99ea36344b1242cafdf1f6b3
|
|
| BLAKE2b-256 |
5503754dc248aae17c01e604c2a6fdddc77ef180f71e5e01d03d40ae87899047
|
File details
Details for the file gunspec-0.2.0-py3-none-any.whl.
File metadata
- Download URL: gunspec-0.2.0-py3-none-any.whl
- Upload date:
- Size: 86.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
uv/0.12.13 {"installer":{"name":"uv","version":"0.12.13","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
037ba97a1eba57b82b110001f9039a4fab28fc8778d457a497645e411134df71
|
|
| MD5 |
259f668cdc12a555f45a2839a00399c6
|
|
| BLAKE2b-256 |
2091ceb8f21410246568f91da87794d45e3d9ca5e686db73543d1a606dacd833
|