norcaster-discover
Find the AI systems a company already runs, see which ones bypass Norcaster, and get a draft plan for moving each one behind the gateway.
It runs inside the customer's environment, reads only, and has no runtime
dependencies (Python standard library). Everything it produces is a local file the customer
can review. Only connect and sync call the Norcaster API, and only to pair and to push those
files (see Paired collector).
The quickest start, with a pairing code from the Discovery setup page (Run in your network):
uvx norcaster-discover connect NCD-XXXXX-XXXXX && uvx norcaster-discover sync --repo .
scan ──► inventory.json ──┬─► report.md (assessment readout)
├─► ingest/*.json (POST /coverage/ingest → estate-discovery triage)
├─► upload.json (POST …/estate-discovery/inventory → Discovery candidates)
├─► plan.json (draft Norcaster API calls, never executed)
└─► patches/ (git patch + PR description per system; repos untouched)
Privacy contract
The inventory holds metadata only: file paths, line numbers, SDK/provider/model names, environment-variable names, destination hosts and call counts.
It never records source code, prompts, payloads, key values, client IPs, user names or
URLs. Hard-coded keys are reported by location (path:line) only. From network logs,
only AI-related destinations survive; every other host and every other column is dropped
at read time. The ingest payload matches the strict schema of POST /coverage/ingest
(bare host, count, window, source) and nothing else.
Install and run
pip install ./packages/norcaster-discover # or: PYTHONPATH=src python -m norcaster_discover
# 1. Scan repos and network logs
norcaster-discover scan \
--repo ~/src/support-chatbot --repo ~/src/platform \
--egress proxy-2026-09.csv --egress-source proxy \
-o inventory.json
# 2. Human-readable inventory for the readout
norcaster-discover report inventory.json -o report.md
# 3. Feed direct provider traffic into Norcaster's shadow-AI triage queue
# (NORCASTER_INGEST_KEY: an org-bound key with only the coverage:ingest scope, see below)
norcaster-discover ingest-payload inventory.json -o ingest/
curl -X POST https://api.norcaster.com/coverage/ingest \
-H "X-API-Key: $NORCASTER_INGEST_KEY" -H "Content-Type: application/json" \
--data @ingest/coverage_ingest_001.json
# 3b. List each app-level system on the Discovery page. upload.json holds names, counts, env var
# names and the AI provider hosts the code calls (no paths, lines, URLs or code); review it first. Needs the key of an owner,
# admin or governance user (the ingest key is refused). Or upload inventory.json on the
# Discovery page, which builds the same body in the browser.
norcaster-discover upload-payload inventory.json -o upload.json
curl -X POST https://api.norcaster.com/orgs/<org id>/estate-discovery/inventory \
-H "X-API-Key: $NORCASTER_API_KEY" -H "Content-Type: application/json" --data @upload.json
# 4. Draft the migration
norcaster-discover plan inventory.json --org-id org_… --project-id prj_… -o plan.json
# 5. Patches and PR descriptions, one per system (the repos are not modified)
norcaster-discover patch inventory.json --repo ~/src/support-chatbot --repo platform=~/src/platform -o patches/
cd ~/src/support-chatbot && git checkout -b norcaster/support-chatbot-openai \
&& git apply ../patches/support-chatbot-openai.patch \
&& gh pr create --title "Route support-chatbot through the Norcaster gateway" --body-file ../patches/support-chatbot-openai.md
patches/SUMMARY.md lists every patch with its apply command, and every system that was
not patched with the reason.
Paired collector
connect trades a pairing code from the Discovery setup page (one use, valid 15 minutes) for a
key that carries only discovery:push, bound to your organization. It saves the key in
~/.config/norcaster/discover.json (or $XDG_CONFIG_HOME, %APPDATA% on Windows,
NORCASTER_DISCOVER_CONFIG to choose), readable by you only.
sync takes the same options as scan, writes inventory.json and upload.json to
.norcaster-discover/ for review, and pushes exactly those: the upload body to
POST /estate-discovery/inventory and the log counts to POST /coverage/ingest. The systems
appear on the Discovery page at once.
norcaster-discover sync --repo ~/src/support-chatbot --egress proxy.csv --egress-source proxy
norcaster-discover sync --repo . --dry-run # write the files, push nothing
norcaster-discover sync --repo . --open-prs # also open one pull request per system
norcaster-discover sync --kubernetes # where the cluster's AI keys come from
-
The key can push and nothing else. It cannot read the Discovery page, not even what it pushed, and cannot call the gateway or mint keys. It is a service-account key, so every push is attributed to the collector. Removing the collector on the setup page revokes it, and it stops working when the person who made the code leaves the organization.
-
Only four calls:
POST /collectors/pair,POST /estate-discovery/inventory,POST /estate-discovery/secret-names(with--kubernetes) andPOST /coverage/ingest, over https (or to localhost). -
In CI:
connect <code> --print-keyprints the key instead of saving it. Store it as the secretNORCASTER_DISCOVER_KEY;syncreads it from there (and the API URL fromNORCASTER_API_URL). The GitHub Action in this repository does that on a schedule. -
--open-prsapplies each system's patch on its own branch,norcaster/<system id>, and opens a pull request with your owngitandgh. It never force-pushes, never pushes another branch, and never merges. A repository with uncommitted changes, or a system whose branch already exists here or on the remote, is skipped and said so. -
--kubernetesreads the cluster's Deployments, StatefulSets, DaemonSets, Jobs and CronJobs with your ownkubectl(its current context, or--k8s-context; every namespace, or--k8s-namespace). For every env var named like an AI key, it notes where the value comes from:- a Secret key, or a whole Secret through
envFrom; - a ConfigMap, where a key is not secret at all;
- a value written into the spec.
It writes
k8s-secret-refs.jsonand pushes it. Sent: the names of the Secrets or ConfigMaps, their keys, the env vars and the workloads, plus a count of the other references. Never a Secret, a ConfigMap or any value: those objects are never read, and a value in a spec is dropped in memory. The RBAC it needs isgetandliston those workload kinds (andlist namespacesfor every namespace), not on Secrets.--k8s-cluster-namesets the name shown; the default is the context's name. - a Secret key, or a whole Secret through
The ingest key
Don't use an admin key for step 3. Mint a key that carries only the coverage:ingest scope
and is bound to your organization: in the portal under Settings → API keys (pick the
organization, tick only coverage:ingest), or as a key on a service account under
Access → Service accounts & agents. Over the API, with your own key:
curl -X POST https://api.norcaster.com/auth/api-keys \
-H "X-API-Key: $NORCASTER_API_KEY" -H "Content-Type: application/json" \
-d '{"name": "egress-ingest", "org_id": "<org id>", "scopes": ["coverage:ingest"]}'
With only that scope the key can call POST /coverage/ingest and nothing else. It has no
read access, can't reach the gateway and can't mint or rotate keys. Rotate it by minting a
new one and revoking the old one.
Log formats
Auto-detected per file: CSV/TSV with a header row, JSON lines, or Squid native access
logs. Host and time columns are found by common names (host, dest_host, query_name,
url, …; timestamp, time, @timestamp, …); override with --host-field and
--time-field. A count column is honoured for pre-aggregated exports. Rows without a
parseable host or time are counted as unparsed, never guessed.
Traffic to api.norcaster.com (add your own gateway hosts with --norcaster-host) is
counted as governed and never sent as ungoverned. Consumer AI apps (chatgpt.com, claude.ai,
…) stay in the local inventory and are left out of the ingest payload unless
--include-consumer-apps is given. OpenAI's data-residency hosts (eu.api.openai.com and
the others) count as OpenAI traffic. The global Vertex endpoint aiplatform.googleapis.com
is kept and sent too, but Norcaster triages it as an unknown host: its Vertex provider can't
route the global location.
What the plan contains
For each ungoverned system with a supported provider, the draft follows the portal's own
onboarding sequence (portal/lib/onboarding-ai-setup.ts):
POST /providers(once per provider type) withresidency_region, plus the Azure base URL, Bedrock region or Vertex location when the scan saw them; credentials are a manual portal step and are never written to the plan. OpenAI gets one provider per OpenAI host the systems use: an app on one of OpenAI's data-residency hosts (eu.api.openai.com,us.api.openai.com, …) gets a provider with that host as itsbase_url, so moving it to Norcaster keeps its traffic in the region.residency_regionis prefilled (eu,us,me) where OpenAI processes data in the region, and left required where it only stores it there. A system whose host the scan can't tell (its code names none and the logs show several) gets a required provider choice instead of the default host. The global Vertex endpoint (aiplatform.googleapis.com,location="global") makes the location a required regional value: Norcaster's Vertex provider doesn't support the global location.POST /deploywithmodel_id,project_idand a draftpurpose. Data classification and legal owner are listed as required inputs.POST /providers/inference-targetsbinding the deployment to the model seen in code.POST /deployments/{id}/apply-packwith a suggested pack and the reason for it.- A manual step to set
contextual_policy.mode = monitorwith amonitor_mode_deadline_iso(default 14 days), after which the pack blocks on its own. - Code changes per call site: point the client at the gateway, add
X-Norcaster-Deployment-Id, move keys toNORCASTER_API_KEY/NORCASTER_DEPLOYMENT_ID.
<<REQUIRED …>> marks a value only the customer can supply; {deployment_id:…} and
{provider_model_id:…} are resolved from earlier responses. Systems already routed through
Norcaster, dependency-only findings and unsupported providers are listed under
not_migrated with the reason.
Where a client sends its traffic
An OpenAI or Anthropic client is attributed by its base URL, not by the SDK's name. The
scanner reads the base_url / baseURL / openai_api_base / api_base /
anthropic_api_url argument of OpenAI, AsyncOpenAI, Anthropic, AsyncAnthropic,
ChatOpenAI, OpenAIEmbeddings, ChatAnthropic, createOpenAI, createAnthropic and
litellm.completion / embedding. It uses a string literal as is. It resolves an
environment variable (os.environ[…], os.getenv(…), process.env.…, import.meta.env.…)
from the same component's .env*, YAML (including Kubernetes name: / value: pairs) or TOML
files, and falls back to the lookup's literal default. With no argument, it resolves
OPENAI_BASE_URL, OPENAI_API_BASE or ANTHROPIC_BASE_URL the same way. Only the host is
kept, never the URL.
| The base URL's host is | Provider label | endpoint_kind |
Migration |
|---|---|---|---|
unset, or the provider's own API (api.openai.com, eu.api.openai.com, …) |
the SDK's provider (or the host's) | provider |
as before |
a Norcaster gateway (api.norcaster.com, --norcaster-host) |
the SDK's provider, status governed |
norcaster |
none |
a third-party AI gateway: gateway.ai.cloudflare.com, api.portkey.ai, aigw.portkey.ai, ai-gateway.helicone.ai, gateway.helicone.ai, oai.helicone.ai, anthropic.helicone.ai, OpenRouter, Vercel AI Gateway |
cloudflare_ai_gateway, portkey, helicone, openrouter, vercel_ai_gateway |
third_party_gateway |
manual_review |
private or cluster-local (localhost, private IPs, short names, .local, .internal, .svc; the same rule the API uses for provider base URLs) |
self_hosted_openai_compatible / self_hosted_anthropic_compatible |
self_hosted |
base_url_swap (OpenAI wire) / manual_review (Anthropic wire) |
| any other public host | openai_compatible_unknown / anthropic_compatible_unknown |
unknown |
manual_review |
| built at run time, or an environment variable no file in the component sets | the SDK's provider | unresolved |
as before |
A component with differently routed clients yields one system per label. The local inventory
records each system's endpoint_kind, its base_url_hosts and, for a self-hosted server on a
well-known port, an engine_hint (11434 → ollama, 8000 → vllm, 8080 →
llama_cpp_or_tgi). None of these three is uploaded: the upload carries only the provider
label.
In the plan, a self-hosted OpenAI-wire server becomes a huggingface provider. That adapter
serves any OpenAI-wire server: vLLM, TGI, llama.cpp, Ollama's /v1. Its base_url is a
required input that the plan never copies from the scan. Norcaster accepts a private origin only
on a self-hosted install or for an organization with a hybrid data plane, and the gateway must
be able to reach it. Gateway, unknown-host and self-hosted Anthropic-wire systems are listed
under not_migrated, and in SUMMARY.md under "Not patched", with the reason. For a gateway
the reason says: decide whether Norcaster replaces the gateway or sits in front of it.
Migration types
| Type | Meaning |
|---|---|
base_url_swap |
OpenAI/Anthropic SDK in Python, JS/TS, Go, Java/Kotlin or .NET (or LangChain, Vercel AI SDK, LiteLLM): change base URL and headers |
client_swap |
Azure OpenAI client (Python/JS AzureOpenAI, .NET AzureOpenAIClient, Go azure.WithEndpoint / DefaultAzureConfig): swap to the plain OpenAI-compatible client, then as above |
client_rewrite |
Native cloud SDK (boto3 bedrock-runtime, AWS SDK): rewrite the client, or leave it listed as ungoverned |
manual_review |
The scanner won't guess (Google GenAI SDK, raw HTTP, Anthropic-on-Bedrock/Vertex) |
developer_config |
Coding assistants: base URL and headers on developer machines |
What the patches change
Each rewrite replaces one client construction and nothing else: it points the client at the
gateway (NORCASTER_BASE_URL, default https://api.norcaster.com), authenticates with
NORCASTER_API_KEY, and sends X-Norcaster-Deployment-Id from NORCASTER_DEPLOYMENT_ID.
Other options (timeouts, retries, extra headers) are kept; a stale X-Quantlix-* header is
removed; Azure clients become the plain OpenAI-compatible client, and their endpoint and API
version move into the Norcaster provider config.
| Rewritten automatically | Python | JavaScript / TypeScript |
|---|---|---|
| OpenAI SDK | OpenAI, AsyncOpenAI |
new OpenAI({...}) |
| Azure OpenAI | AzureOpenAI, AsyncAzureOpenAI → OpenAI |
new AzureOpenAI({...}) → new OpenAI |
| Anthropic SDK | Anthropic, AsyncAnthropic |
new Anthropic({...}) |
| LangChain | ChatOpenAI, OpenAIEmbeddings, ChatAnthropic, Azure variants → OpenAI |
— |
| Vercel AI SDK | — | createOpenAI, createAnthropic |
The official Go, Java / Kotlin and .NET SDKs are found (confidence high) and, for the shapes
below, rewritten too. A client counts only in a file that imports the SDK, so a local package or
class with the same name (openaiformat.NewClient, your own ChatClient) is not taken for one.
| Rewritten automatically | Client construction | What the rewrite does |
|---|---|---|
| openai-go, anthropic-sdk-go | openai.NewClient(opts...), anthropic.NewClient(opts...) |
Appends option.WithBaseURL, WithAPIKey (and WithAuthToken for Anthropic) and the X-Norcaster-Deployment-Id header; options apply in order, so these win. Adds the cmp, os and option imports. |
| go-openai | openai.NewClient(key), key a literal or os.Getenv("…") |
openai.NewClientWithConfig with BaseURL. go-openai has no header option, so the key must be bound to one deployment. |
| openai-java, anthropic-sdk-java (Java and Kotlin) | OpenAIOkHttpClient(Async).fromEnv() / .builder() … .build() in one chain |
A builder with .apiKey, .baseUrl and the deployment header (.authToken too for Anthropic); other builder calls are kept. |
| OpenAI .NET | OpenAIClient, ChatClient, EmbeddingClient given a model and a key |
An ApiKeyCredential and OpenAIClientOptions { Endpoint = … }. No header option, so the key must be bound to one deployment. |
Anthropic .NET (official Anthropic) |
new AnthropicClient() / new(), with or without an initializer |
ApiKey and BaseUrl in the initializer; same bound-key rule. |
| Found, changed by hand | Client construction | Why |
|---|---|---|
| openai-go Azure, go-openai Azure | azure.WithEndpoint(...), openai.DefaultAzureConfig(...) |
Switching an Azure client changes its types. |
| go-openai config | openai.DefaultConfig(...) |
The config is changed and used elsewhere. |
| OpenAI .NET Responses | ResponsesClient, OpenAIResponseClient |
Its options type differs by SDK version (2.11 changed it). |
| Azure.AI.OpenAI | AzureOpenAIClient |
As for Azure above. |
| Anthropic .NET, community | AnthropicClient with using Anthropic.SDK; |
A different SDK; labelled anthropic-sdk-dotnet. |
The Go output is gofmt-clean. The rewrites were compiled against openai-go v3.69.0, anthropic-sdk-go v1.78.0, go-openai v1.43.0, openai-java 4.73.0, anthropic-java 2.68.0, OpenAI .NET 2.14.0 and Anthropic .NET 12.53.0 (2026-10-01).
Embeddings get their own deployment (NORCASTER_EMBEDDINGS_DEPLOYMENT_ID), because a
Norcaster deployment binds one model. A client pointed at a self-hosted OpenAI-wire server is
rewritten the same way; the server's origin moves into the Norcaster provider config.
It refuses rather than guesses. These stay with a person, listed in the PR description
under "Needs a human" or in SUMMARY.md under "Not patched":
- Services that also call provider APIs the gateway does not route. The gateway serves
chat completions, messages, embeddings and — since the
/v1/responseschange of 2026-09-29 — the OpenAI Responses API. A service that also uses Assistants, Realtime, batches, Anthropic files or Anthropic Managed Agents is not patched, because pointing its client at the gateway would break those calls. Against an older gateway, scan with--gateway-apis chat.completions,messages,embeddings.
OpenAI files, vector stores, conversations and containers are different: the same
gateway change forwards them once the deployment opts in
(action_policies.forward_unchecked). A service that uses them is patched and planned; the
inventory lists them under opt_in_api_calls, the plan adds a step that sets
forward_unchecked for exactly those APIs, and the PR description says what to enable and
what it means — the calls are recorded but their content is not policy-checked, and files or
vector stores created directly at the provider must be re-created through the gateway.
--gateway-opt-in-apis '' treats them as unsupported instead. The same names belong to
other libraries too (Docker or LXD containers, Slack or Twilio conversations, Gemini, Mistral,
Together or Groq files): a call made on one of their objects is taken to be theirs. What the
receiver holds is worked out where the call is — in its function, then the enclosing ones,
then the module (Python, read with ast), or its block and the outer ones (JavaScript /
TypeScript), self.x / this.x in its class — so one name can hold a Docker client in one
function and an OpenAI client in another. Every other call counts, including one through an
OpenAI client imported from another module; a module-level name assigned both kinds of client
counts (a function may run after either assignment), and so does a name assigned one kind in an
if and the other in its else before the call, or assigned an expression that may build
either (docker.from_env() if local else OpenAI()). A name rebound from another function through
global, and code inside JavaScript template strings, are not followed.
- Clients sent through a third-party AI gateway, to an unknown public host, or to a self-hosted Anthropic-wire server (see "Where a client sends its traffic").
- Positional arguments,
**kwargsor spread options, options built elsewhere, imports it cannot resolve, and code that changed since the scan. - A provider key that came from a variable rather than the environment (per-user or
per-tenant keys): the patch is written, but flagged, because every call will now use
NORCASTER_API_KEY. - Go, Java / Kotlin and C# shapes outside the table above: an option slice (
opts...), a go-openai key in a variable (dropping it would leave the variable unused; openai-go keeps such an option and appends its own), a Java builder built elsewhere, a .NET client already given options. - Native cloud SDKs (boto3
bedrock-runtime), Google GenAI, LangChaininit_chat_model, LiteLLM, other gateways (Vercel AI Gateway), notebooks, and test code (unless--include-tests).
Every Python result is re-parsed and every JS/TS result bracket-checked; a rewrite that fails the check is dropped. Patches contain the customer's code, so unlike the inventory they are meant to stay in the customer's environment.
What it can't see
Clients built dynamically or hidden behind internal wrappers; SaaS AI features such as Microsoft 365 Copilot; personal use of consumer AI apps outside the supplied logs; traffic that leaves by a path the logs don't cover. The report says so in its last section.
Base URLs are only followed as far as the component's own files. A base URL built at run
time, or an environment variable set outside the component (a CI secret, a Helm value in
another repo), is reported as unresolved, and the client keeps its SDK's provider. A
gateway on a custom domain (Cloudflare custom domains, a self-hosted Portkey) is reported as
an unknown or self-hosted host, not as that gateway. Cloudflare's REST route on
api.cloudflare.com is reported as unknown: that host is all of Cloudflare's API.
Norcaster's provider usage reconciliation reads OpenAI's Usage API on api.openai.com.
OpenAI doesn't document whether usage of a data-residency project (one on
eu.api.openai.com or another regional host) is reported there, so reconciliation's coverage
of those projects is unverified.
In Go, Java / Kotlin and .NET, only calls to the four APIs the gateway routes are recognised
(chat completions, messages, embeddings, responses, in each SDK's own spelling). Assistants,
Realtime, batches, files and other provider APIs called from these languages are not seen, so
such a service can look ready to migrate when it isn't: check it by hand. Clients there are
recognised by the SDK's default package name and an import in the same file: a Go import under
another alias, or a .NET global using in a different file, is missed (the service then shows up
from its manifest, with confidence low).
Tests
cd packages/norcaster-discover && python -m pytest
CI runs the suite in the scanner-tests job on Python 3.10 (the floor) and 3.14, with nothing
installed but pytest, so a dependency beyond the standard library fails there:
cd packages/norcaster-discover && uv run --isolated --no-project --python 3.10 --with "pytest>=7" python -m pytest -q
test_signatures.py checks the host mapping against the API's classify_endpoint when the
API is importable, and skips otherwise. The suggested-pack rules live in pack_rules.py,
which is copied byte for byte to api/governance/pack_rules.py so the hosted recommender
suggests the same pack; test_pack_rules.py compares the two copies when the API is in the
checkout, and the API's tests fail when they differ.
Metadata
Release files for norcaster-discover 0.2.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 | Provenance | Actions |
|---|---|---|---|---|
| norcaster_discover-0.2.0.tar.gz | 129.7 kB | With provenance | Details for norcaster_discover-0.2.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| norcaster_discover-0.2.0-py3-none-any.whl | Python 3 | none | any | Details for norcaster_discover-0.2.0-py3-none-any.whl |
Total release size: 236.7 kB
Release files / norcaster_discover-0.2.0.tar.gz
| Download URL | norcaster_discover-0.2.0.tar.gz |
|---|---|
| Size | 129.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
51e798486fa6a9c5c0c13755e5d5f9d1c17500435b3591cf0c1f61b578cb6fc8
|
|
BLAKE2b-256 checksum How to use checksums |
ab7b8a19c1a5858b0b24f4089517ce5fce723f1ee49de0a4fa2b8e8d201a8f67
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 2, 2026.
Transparency logRelease files / norcaster_discover-0.2.0-py3-none-any.whl
| Download URL | norcaster_discover-0.2.0-py3-none-any.whl |
|---|---|
| Size | 107.0 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
393bae5cc47a960a4e544ebb96a6c3bbd5f7340642a36b026dd230302c24e3f4
|
|
BLAKE2b-256 checksum How to use checksums |
76ab00783c4b1610e645283a41014ad0f1bc844ffe9dc67159556d7394dd1da6
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 2, 2026.
Transparency logSecurity for norcaster-discover 0.2.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
Both files in this release have been uploaded with provenance metadata.
Attestation for norcaster_discover-0.2.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 2, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.