tamga-sdk
Official Python SDK for Tamga. Integrate license activation, offline verification, and machine management into your Python applications.
Pure Python — no Rust extension, no native build step. Every cryptographic primitive (Ed25519,
RSA-PKCS1/PSS, ECDSA-P256, AES-256-GCM, HKDF-SHA256) comes from the
cryptography package; HTTP transport is
httpx.
Install
pip install tamga-sdk
Requires Python 3.9+. The distribution is named tamga-sdk (the bare tamga name on PyPI
belongs to an unrelated logging library); the importable package is tamga:
import tamga
Quickstart
from tamga import TamgaClient, TamgaConfig
from tamga.transport import LicenseAuth
config = TamgaConfig(
account_id="your-account-id",
host="api.tamga.sh",
default_auth=LicenseAuth(key="YOUR-LICENSE-KEY"),
)
with TamgaClient(config) as client:
result = client.licenses.validate_by_key("YOUR-LICENSE-KEY")
if result.meta.valid:
print("License is valid:", result.meta.code.value)
else:
print("License is not valid:", result.meta.code.value, result.meta.detail)
result.meta.code is a ValidationCode enum member (VALID, EXPIRED, SUSPENDED,
TOO_MANY_MACHINES, …). An unrecognized code from a newer server deserializes to
ValidationCode.UNKNOWN instead of raising.
Runnable end-to-end scripts live in examples/:
validate_license.py— validate by key, by ID with a scope, and the lightweight quick-validateGET.checkout_and_verify.py— offline.liccheckout, plain and encrypted, through the full verify pipeline.machine_activation_flow.py— create machine → validate → roll back on over-limit.heartbeat_scheduler.py— machine (window read from the policy, 600s only as a fallback) vs. process (30s window) heartbeat scheduling side by side, including disposing of the process row afterwards.offline_proof.py— air-gapped machine proof generation and verification.
Auth transports
Four of the server's five transports are modeled (src/tamga/transport.py::apply_auth).
Session-cookie auth is browser/portal-only — it requires a matching Origin header and is
deliberately out of scope for a non-browser SDK.
from tamga import TamgaConfig
from tamga.transport import BasicAuth, BearerAuth, LicenseAuth, QueryParamAuth
# 1. Bearer token
TamgaConfig(account_id="...", host="api.tamga.sh", default_auth=BearerAuth(token="tok-..."))
# 2. HTTP Basic — three sub-forms
TamgaConfig(
account_id="...",
host="api.tamga.sh",
default_auth=BasicAuth(email="you@example.com", password="..."),
)
TamgaConfig(account_id="...", host="api.tamga.sh", default_auth=BasicAuth(token="tok-..."))
TamgaConfig(account_id="...", host="api.tamga.sh", default_auth=BasicAuth(license_key="..."))
# 3. License key — the primary transport for embedded/client apps
TamgaConfig(account_id="...", host="api.tamga.sh", default_auth=LicenseAuth(key="YOUR-KEY"))
# 4. Query parameter
TamgaConfig(account_id="...", host="api.tamga.sh", default_auth=QueryParamAuth(value="tok-..."))
Every issued token carries a tok- prefix regardless of its documented type — treat tokens as
opaque strings and do not build prefix-based type detection.
licenses.validate_by_key(key) falls back to Authorization: License <key> for the key being
validated when no default_auth is configured, since it already holds the credential
(src/tamga/client.py::LicensesClient.validate_by_key).
License-key auth must be enabled on the policy. Authorization: License <key> (and the
license:<key> Basic sub-form) is only accepted when the license's policy sets
authentication_strategy to "LICENSE" or "MIXED". That column defaults to "TOKEN", and
"NONE" behaves the same way at the auth gate — under either the server answers
401 LICENSE_NOT_ALLOWED, raised as tamga.errors.LicenseNotAllowedError. That is a
configuration precondition to fix on the policy, not a transient failure: retrying the same key
never succeeds, and it does not mean the key is wrong. Separately, a policy with
expiration_strategy: "REVOKE_ACCESS" stops an expired license from authenticating at all
(401 LICENSE_EXPIRED); the other three expiration strategies still authenticate it and report
expiry through the validation result instead.
The host may be given with an explicit http:// scheme for a self-hosted or local deployment —
it is preserved, not silently upgraded. A bare host defaults to https.
Activating a machine and keeping it alive
from tamga.client import HeartbeatScheduler
with TamgaClient(config) as client:
# Idempotent: a repeat activation of the same fingerprint returns the
# machine that already exists instead of raising `409 FINGERPRINT_TAKEN`.
machine = client.machines.activate_machine_idempotent(license_id, fingerprint)
# Size the ping interval from the policy that actually sets the window.
# 600s is only what the server falls back to when `heartbeat_duration` is
# unset — a 120s policy needs a 40s ping, not the 200s default.
policy = client.licenses.get_policy(license_id)
HeartbeatScheduler.for_policy(client.machines, machine.id, policy).run_forever()
Read the policy through licenses.get_policy(license_id), not policies.get(policy_id): the
former is gated on license.read, which a license key holds; the latter on policy.read, which
it does not, so it answers 403 under license-key auth.
Both schedulers also accept an interval directly, and that value is not honoured verbatim
below one second: a non-positive one becomes the scheduler's recommended default (200s machine,
10s process) and a positive sub-second one is raised to MIN_HEARTBEAT_INTERVAL, so
timedelta(milliseconds=500) pings once a second. Nothing raises. That is a busy-loop guard
rather than a rounding convenience — time.sleep honours a sub-second request, so an
unguarded timedelta(microseconds=1) is not a fast heartbeat but roughly 163,000
ping-heartbeat requests a second, each individually valid and correctly authenticated. The
floor costs nothing a policy can ask for, since heartbeat_duration is an integer-seconds
column and the server judges liveness on truncated whole seconds — a machine first reads DEAD
at window_secs + 1, so even a 1s window has two seconds of slack at a 1s ping. What it does
cost is loss tolerance on windows under 3s; the full table is in tests/test_policy_read.py.
machines.get(machine_id) is the read path where heartbeat_status is a genuine staleness
verdict — the ping/reset/create routes each derive the status from a timestamp they just wrote, so
they can never report DEAD. DEAD still never means the row was culled; only a 404 from the
ping does.
A process is the mirror image, because nothing on the server reaps process rows:
from tamga.client import ProcessHeartbeatScheduler
with TamgaClient(config) as client:
process = client.processes.create(machine.id, pid=str(os.getpid()))
# `dispose` on exit stops the loop *and* deletes the row, freeing its slot
# against `policy.max_processes` — `stop()` alone would leak it.
with ProcessHeartbeatScheduler(client.processes, process.id) as scheduler:
scheduler.run_forever()
Checking for updates
with TamgaClient(config) as client:
release = client.releases.check_for_upgrade(
product_id=product_id,
platform="darwin-arm64",
filetype="dmg",
version=__version__,
)
if release is None:
# NOT "you are up to date". The server answers `204 No Content` both when
# nothing newer exists and when something newer exists that this license is
# not entitled to, deliberately, so that a denial cannot leak the latter.
print("No update is available to you.")
else:
print("Update available:", release.version)
Diagnosing a misconfigured deployment
with TamgaClient(config) as client:
print(client.health())
GET /v1/health is the one route outside /v1/accounts/{account_id}, and it is exempt both from
the auth gate and from the server's Host-header allowlist. So if every other call fails with
403 and "The Host header does not match any configured host" while this one succeeds, the
problem is the server's TAMGA_ALLOWED_HOSTS configuration — not the caller's credential. If this
one fails too, the server is unreachable and no credential would have helped.
Offline verification
.lic license files and machine files verify entirely offline once the account's public key is
embedded in your application — no network round-trip per check.
from tamga.checkout.license_file import LicenseFile, LicenseFileExpired
# The account's raw 32-byte Ed25519 public key, embedded in your application.
ACCOUNT_PUBLIC_KEY = b"...32 bytes..."
with TamgaClient(config) as client:
checkout = client.licenses.check_out(license_id, ttl=86_400)
assert not isinstance(checkout, bytes) # the POST variant returns a LicenseFileResource
license_file = LicenseFile.parse(checkout.certificate)
try:
license_resource = license_file.verify(ACCOUNT_PUBLIC_KEY)
except LicenseFileExpired as exc:
print("license file expired at unix timestamp", exc.exp)
else:
print("verified:", license_resource.id)
Pass as_bytes=True to use the GET variant instead, which returns the raw .lic bytes with no
surrounding metadata. For an encrypted checkout, supply the license key so the AES key can be
derived, and use verify_with_claims when you want the signed jti (replay detection) or kid
(key rotation):
encrypted = client.licenses.check_out(license_id, encrypt=True, ttl=86_400)
assert not isinstance(encrypted, bytes)
license_resource, claims = LicenseFile.parse(encrypted.certificate).verify_with_claims(
ACCOUNT_PUBLIC_KEY,
license_key="YOUR-LICENSE-KEY",
)
print(claims.iat, claims.exp, claims.jti, claims.kid)
⚠️ Compatibility break: license files must be format v2.
algmust bebase64+ed25519+v2oraes-256-gcm+ed25519+v2; every v1-issued.licfile is rejected with aValueErrorand there is no fallback path (src/tamga/checkout/license_file.py::LicenseFile.parse). If you hold v1 files, re-check them out against a v2 server. In v1 the requestedttl/expirylived only in the JSON:API envelope around the certificate, so a 24-hour trial file stayed cryptographically valid forever; accepting both formats would hand that behavior back.
Machine files use the same {enc, sig, alg} envelope but dispatch signature verification on the
license's own scheme (ED25519_SIGN, RSA_2048_PKCS1_SIGN, RSA_2048_PKCS1_PSS_SIGN,
ECDSA_P256_SIGN) via src/tamga/checkout/machine_file.py::MachineFile.verify.
from tamga.checkout import MachineFile
from tamga.checkout.machine_file import MachineFileExpired
from tamga.models.policy import LicenseScheme
machine_file = MachineFile.parse(certificate)
try:
machine, claims = machine_file.verify_with_claims(
ACCOUNT_PUBLIC_KEY,
LicenseScheme.ED25519_SIGN, # from the license, never from the file's own `alg`
license_key="YOUR-LICENSE-KEY", # encrypted files only
fingerprint=THIS_MACHINE_FINGERPRINT, # encrypted files only
)
except MachineFileExpired as exc:
... # authentic but lapsed -> check out a fresh one; `exc.exp` says when
print(machine.heartbeat_status, claims.jti, claims.kid)
Machine files are format v2 as well:
algcarries the mandatory+v2suffix —base64+ed25519+v2,aes-256-gcm+rsa-pss-sha256+v2, and the six other combinations of encoding prefix and signing suffix. A file without it is rejected with no fallback, for the same reason a v1.licis.meta.expis enforced, sharingCLOCK_SKEW_TOLERANCE_SECONDSwith the.licpath and raisingMachineFileExpired— a subclass ofLicenseFileExpired, so oneexceptclause covers both file types.expis optional by design: a checkout made without attlproduces a file that genuinely never expires. Passverify(..., now=<server-supplied timestamp>)when defending against a rewound clock.- An encrypted machine file's
encis"<nonce_b64>.<cipher_b64>"— two separately base64-encoded halves, not the singlebase64(nonce ‖ ciphertext ‖ tag)blob a.licuses. The signature covers the wholeencstring, so verification happens before the split.
src/tamga/proof.py::ProofResult.verify covers the lighter air-gapped machine offline proof.
Security notes
- Both offline-file AES keys are HKDF-SHA256 derived. License file:
salt = "tamga:license-file-key-v1",ikm = the license key,info = "license-file"(src/tamga/crypto/hkdf.py::derive_license_file_key). Machine file:salt = "tamga:machine-file-key-v1",ikm = the license key,info = the machine's fingerprint(src/tamga/crypto/hkdf.py::derive_machine_file_key), so a machine file only decrypts on the machine it was issued for. The former zero-pad/truncate license-file transform was removed, not deprecated — the module that implemented it no longer exists. - Signed expiry is enforced, not advisory. Format v2 moves
iat/exp/jti/kidinside the signed bytes, andsrc/tamga/checkout/license_file.py::LicenseFile.verifyrejects an expired file withLicenseFileExpiredusing a deliberately small 60-second clock-skew tolerance (CLOCK_SKEW_TOLERANCE_SECONDS). The client's clock is under the attacker's control, so passverify(..., now=<server-supplied timestamp>)if you are defending against a rewound clock.LicenseFile.is_expired()reads the unsignedexpirymetadata and is advisory only. - Signatures cover
enc's base64 string, not its decoded bytes. Both file types signenc.encode("ascii")(src/tamga/checkout/license_file.py::LicenseFile.verify). It is the easiest thing to get backwards when reimplementing verification. schememust come from an authenticated response. FeedMachineFile.verify(..., scheme=...)from the license's ownschemefield, never from the certificate's ownalgstring —algsits in the unsigned outer envelope and is not covered by the signature (src/tamga/checkout/machine_file.py, module docstring).RSA_2048_JWT_RS256is rejected up front withSchemeNotSupportedError, never falling through to another verifier.- HTTP 429 is live and handled.
src/tamga/client.py::_request_with_retryretries while the server answers429.src/tamga/client.py::_retry_delayprefers the server'sRetry-Afterbut caps it at 60s, otherwise using jittered exponential backoff so a fleet does not reconverge into the spike it was backing off from.src/tamga/client.py::_is_retryablescopes auto-retry to everyGETplus exactly sevenPOSTactions —validate,validate-key,check-in,check-out,ping,ping-heartbeat,reset-heartbeat— because those are the calls a client makes on a timer. (ping-heartbeatdoes not end with/actions/ping; that suffix matches only the process route, so it needs its own entry.) Creates are deliberately excluded: retryingPOST /machinesrisks burning a second seat. Tune withTamgaConfig(max_retries=...);0disables retries and the raisedtamga.errors.RateLimitedErrorstill carriesretry_after. - The license read routes are not scoped to the calling license.
licenses.get(license_id)andlicenses.get_policy(license_id)reachGET /licenses/{id}andGET /licenses/{id}/policy, which authorize on thelicense.readpermission and the account resolved from the bearer — and on nothing else. Any license key that authenticates can therefore read every license in the same account, including each one'sattributes.keyin plain text. Both methods are exposed because reading your own policy is how the heartbeat window is discovered at all, but do not mistake the surface for a scoped one: never embed a license key in a context where an attacker recovering it should not also be able to enumerate the account's other keys. This is server-side behaviour the SDK cannot fix; it has been reported upstream. - The machine routes are unscoped too, and three of them write. The server has a
require_license_scopecheck that confines a license credential to its own license, and it is applied to exactly five routes:validate,validate-key,quick-validate, and both license check-out variants. It is applied to no machine route. A license token's default permissions includemachine.read,machine.updateandmachine.delete, somachines.list,machines.get,machines.updateandmachines.deleteall reach every machine in the account — not just the ones on the calling license. Read is the same exposure as the license routes above; update and delete are worse, because they change state. Treat a license key as an account-scoped credential in your threat model, not a license-scoped one. Reported upstream. - Verification failures stay uniform inside a step. A wrong key, a malformed key, and a
tampered message all collapse to one
InvalidSignature(src/tamga/crypto/ed25519.py::verify). The steps themselves remain distinguishable on purpose:InvalidSignature(not authentic),InvalidTag(authentic but decryption failed),LicenseFileExpired(authentic but expired),ValueError(malformed input that never reached a cryptographic operation). except TamgaError:catches everything raised against the server. That includesMachineOverLimitErrorfromactivate_machine, which reports a licence at its machine / core / memory / disk limit. It also subclassesValueError, deliberately and permanently, because both activation rejection paths used to raise a bareValueError— so existingexcept ValueError:handlers keep working. Do not confuse it with the plainValueErrors above: those come from the offline parsers and mean "this input is malformed", where failing closed is correct. An over-limit rejection means the server said no, and carriesvalidation_codeplus arolled_backflag saying whether a machine row was created and then deleted.
Report suspected vulnerabilities privately to security@tamga.sh — see
SECURITY.md.
Known gaps
- Sync only.
TamgaClientwrapshttpx.Client; there is no async client yet. - No session-cookie transport. Browser/portal only, out of scope here.
- No
Tamga-Environmentheader. No server code path reads it yet, so the SDK does not send it. 204from the upgrade check has two meanings, and no client can tell them apart.releases.check_for_upgradereturnsNoneboth when nothing newer exists and when something newer exists that this license is not entitled to. That is deliberate server-side — a denial would leak "a newer version exists but you can't have it" — so reportNoneas no update is available to you, never as "you are up to date". A suspended license is a separate403.- The
releasesresource is camelCase; almost nothing else is.check_for_upgradereturns a release whose owning product arrives asproductId, notproduct_id—ReleaseAttributesis one of only ten attribute structs on the server that carryrename_all = "camelCase". Itscreated/updatedare the exception inside the exception: explicit serde renames override the camelCase rule, so those two are spelled as they are everywhere else. Machines, policies, licenses, components and processes are all snake_case. - There is no exact fingerprint filter on
GET /machines. The server offersfilter[license|owner|group|platform]and a free-textfilter[q], andfilter[q]is a case-insensitive substring match acrossname,hostnameandfingerprint, truncated to 200 characters.machines.find_by_fingerprinttherefore narrows with the search and then compares exactly client-side; both approximations run toward a superset, so it never returns a machine with a different fingerprint, but it costs a scan rather than a lookup. - A listed machine cannot be attributed to a license. The machine resource carries no
license_idand norelationshipsobject, sofilter[license]onmachines.listis the only thing that ties a machine to a license — there is nothing on the resource to check the answer against.find_by_fingerprinttherefore requires alicense_idrather than defaulting to an account-wide scan: an unscoped result is a row the caller cannot attribute and must not act on. A deliberate account-wide search is still available throughmachines.list(search=...), where it is explicit. - A cross-license
409 FINGERPRINT_TAKENis not recoverable, deliberately. All threemachine_uniqueness_strategyvalues include the caller's own license in the duplicate check —UNIQUE_PER_LICENSEmatches its rows exactly,UNIQUE_PER_POLICYjoins on the policy that license already belongs to,UNIQUE_PER_ACCOUNTcovers everything — so a genuine re-activation of the same license and fingerprint is always found and returned. What the two wider scopes add is the cross-license conflict, andactivate_machine_idempotentre-raises rather than returning that machine: it belongs to another license, the caller would heartbeat and check it out while this license'smachines_countstayed at zero, and that is precisely the seat-sharing the wider scopes exist to prevent. GET /policies/{id}always fails under license-key auth. It authorizes on thepolicy.readpermission, which is not in the license-token permission set. Read the policy throughlicenses.get_policy(license_id)instead — same resource, but a route gated onlicense.read, which a license credential does hold.policies.getexists for callers holding a privileged token.- The license read routes are not scoped to the calling license.
GET /licenses/{id}andGET /licenses/{id}/policyauthorize onlicense.readand the account resolved from the bearer, and nothing further — so any license key that authenticates can read every license in the same account,attributes.keyincluded, in plain text. Server-side behaviour this SDK cannot fix and does not work around; reported upstream. policy.max_memoryandpolicy.max_diskare alwaysNone. The server omits both from the policy response even though it enforces them, so the only way to observe either limit is aTOO_MUCH_MEMORY/TOO_MUCH_DISKvalidation code.machines.updatecannot clear a field. Every column is applied throughCOALESCE(new, existing)server-side, so an omitted orNonefield means "leave alone", never "set to null". Its response also judgesheartbeat_status/next_heartbeat_atagainst the 600s fallback rather than the policy window — that query does not joinpolicies. Read the machine back withmachines.getwhen either field matters.- Whether
heartbeat_statuscan sayDEADdepends on whether the server wrote or read. The write-shaped routes preclude it by construction: a heartbeat ping reports the timestamp it just wrote (alwaysALIVEorRESURRECTED), a reset nulls it (NOT_STARTED), and a create never sets it (NOT_STARTED). The read paths do not, somachines.get,machines.listand machine checkout all carry a genuine staleness verdict.PATCHsits between the two: it writes, but never tolast_heartbeat_at, so it can reportDEAD— just judged against the 600s fallback, since that query does not join the policy. ADEADreading still never means the row was deleted, so it is information rather than a stop condition:HeartbeatSchedulerstops for no status at all — onlystop(), cancellation, or a404from the ping (the row is gone — re-activate) ends the loop. - Request bodies are enveloped on some endpoints and flat on others. Responses are JSON:API
documents throughout, but requests are not:
machines.createandmachines.updatesend{"data": {"type", "attributes", ...}}, whilecomponents.createandprocesses.createsend their fields at the top level, because those two handlers deserialize into plain structs. It is a per-endpoint fact with no rule behind it, and normalizing the two to match breaks one of them. - Nothing reaps process rows server-side. The 30s process window exists but no scheduled job
acts on it, so a process that merely stops pinging holds its slot against
policy.max_processesforever. Deleting it is the application's job: callprocesses.delete, or useProcessHeartbeatScheduler.dispose(or the scheduler as a context manager), which stops the loop and deletes the row together. reset_heartbeatandgenerate_offline_proofalways fail under license-key auth. Both are role-gated (admin / developer / product token / environment token), so a license key gets403every time.ping_heartbeatis permission-only and works.X-RateLimit-*response headers are not surfaced.Retry-Afteron a429is the only rate-limit signal this SDK reads (src/tamga/transport.py::parse_retry_after), and only its delta-seconds form is honored — the HTTP-date form is ignored rather than risking a date being misread as a duration.- 8 of the 24
ValidationCodemembers are declared but never emitted today (BANNED,TOO_MANY_USERS,HEARTBEAT_DEAD,HEARTBEAT_NOT_STARTED,COMPONENTS_SCOPE_MISMATCH,NOT_FOUND— which comes back as a raw HTTP 404 — and theCHECKSUM/VERSIONscope mismatches, whose scope keys are rejected outright rather than evaluated). Per-member reachability is documented insrc/tamga/models/validation.py. - Six
LicenseScopefields are enforced —product,policy,user,environment,entitlements, andfingerprint.versionandchecksumare not ignored: sending either makes the server reject the entire validate call with422 SCOPE_NOT_SUPPORTED, so this SDK deprecates them and does not put them on the wire. - License entitlements cannot be paginated. The server ignores
page[after]on/licenses/{id}/entitlements(the listing unions direct and policy-inherited rows), soentitlements.listalways returnsnext_after=Noneandlist_allis a single request capped at 100 rows. A license with more than 100 effective entitlements cannot be enumerated in full, which makes a negativehas_entitlementauthoritative only below that ceiling.components.listis genuinely keyset-paginated and does page to completion. quick_validaterecords nothing if the request carries anOriginheader. The server skips thelast_validated_atwrite and returns a byte-identical response, so a proxy that injectsOriginsilently disables it. This SDK never sendsOrigin; usevalidate_by_idwhen the write matters.- No CLI. The package ships a library only.
Documentation
- tamga.sh — product documentation and the account console.
SECURITY.md— the crypto assumptions an integrator is trusting, and how to report a vulnerability.CLAUDE.md— dense, gotcha-first architecture/crypto reference for anyone modifying this codebase.CONTRIBUTING.md— dev setup, test/lint/type-check commands, PR expectations.- Every public symbol carries a Google-style docstring;
help(tamga.TamgaClient)and your IDE are the API reference until a generated docs site lands.
License
MIT — see LICENSE.
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 tamga_sdk-1.0.4.tar.gz.
File metadata
- Download URL: tamga_sdk-1.0.4.tar.gz
- Upload date:
- Size: 282.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
549ddd95bcf5682e946e1de08816ea3d2c4ab530d8fd762e9ea2a3e2dc86f2d0
|
|
| MD5 |
20562ec26c22615565408bbd33e90497
|
|
| BLAKE2b-256 |
be30c78331a6c212c74a6fc7e84fe810fee429668861f6d360220b598a99e8c0
|
Provenance
The following attestation bundles were made for tamga_sdk-1.0.4.tar.gz:
Publisher:
release.yml on tamga-sh/tamga-python
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
tamga_sdk-1.0.4.tar.gz -
Subject digest:
549ddd95bcf5682e946e1de08816ea3d2c4ab530d8fd762e9ea2a3e2dc86f2d0 - Sigstore transparency entry: 2547082364
- Sigstore integration time:
-
Permalink:
tamga-sh/tamga-python@f2818f419b573df70714a3bbd0e1967be769027a -
Branch / Tag:
refs/heads/main - Owner: https://github.com/tamga-sh
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@f2818f419b573df70714a3bbd0e1967be769027a -
Trigger Event:
push
-
Statement type:
File details
Details for the file tamga_sdk-1.0.4-py3-none-any.whl.
File metadata
- Download URL: tamga_sdk-1.0.4-py3-none-any.whl
- Upload date:
- Size: 104.0 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
9dc2edeeeda3fdfebe6d44a3a856e42669abaabad8007418822c8cf94332a708
|
|
| MD5 |
e54ad3b1a20c352c59f44d3e3a91aa33
|
|
| BLAKE2b-256 |
4409049d28a40b549fc2f3ae9923176b4ca46323e08664f27015ed92f5007cbc
|
Provenance
The following attestation bundles were made for tamga_sdk-1.0.4-py3-none-any.whl:
Publisher:
release.yml on tamga-sh/tamga-python
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
tamga_sdk-1.0.4-py3-none-any.whl -
Subject digest:
9dc2edeeeda3fdfebe6d44a3a856e42669abaabad8007418822c8cf94332a708 - Sigstore transparency entry: 2547082808
- Sigstore integration time:
-
Permalink:
tamga-sh/tamga-python@f2818f419b573df70714a3bbd0e1967be769027a -
Branch / Tag:
refs/heads/main - Owner: https://github.com/tamga-sh
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@f2818f419b573df70714a3bbd0e1967be769027a -
Trigger Event:
push
-
Statement type: