euclid-pdk
Python client library for the euclid server.
It covers the three things everything else needs - the connection, request signing, and EAM, euclid's access management module - and the ten modules an application spends its time in: ESM (storage), EQS (queues), ENS (topics), EES (events), EKM (keys), ESS (secrets), EKV (key-value tables), EAG (the API gateway), EAP (the applications behind it) and ETS (FTP and SFTP onto a bucket). The remaining modules (EMO, EMM, EMD and the rest) speak the same protocol over the same client and will follow.
Requires Python 3.10 or newer, and has no dependencies. Installing this SDK does not bring a TLS stack, an HTTP client and a JSON parser along with it: the wire protocol is JSON over HTTP and the signatures are HMAC-SHA256, all of which the standard library already covers.
Installation
pip install euclid-pdk
From a checkout:
pip install -e ".[dev]"
Usage
Log in once and reuse the session:
from euclid import Euclid
session = Euclid.for_server("https://euclid.example.com").login("jens", "secret")
for user in session.list_users(prefix="j", page_size=25).users:
print(user.user_id, user.email)
Every other module hangs off that session, one client each and one connection each:
esm, eqs, ess = session.esm(), session.eqs(), session.ess()
bucket = esm.create_bucket("reports")
esm.upload_file(bucket.ern, "2026/q3.pdf", "q3.pdf")
queue = eqs.create_queue("orders")
eqs.send_message(queue.ern, '{"order": 17}')
for message in eqs.receive_messages(queue.ern, wait_time=20).messages:
handle(message.body)
eqs.delete_message(message.receipt_handle)
connect(password=ess.get_secret("db-password").value)
The builder form takes the same options one at a time, which reads better when there are several:
from euclid import Euclid, SigningScheme
session = (Euclid.for_server("https://euclid.example.com")
.access()
.credentials("jens", "secret")
.namespace("development")
.signing_scheme(SigningScheme.RFC9421)
.ca_cert_path("/etc/euclid/euclid_cert.crt")
.login())
A session holds a connection, so close it when you are done - or use it as a context manager:
with Euclid.for_server(url).login("jens", "secret") as session:
session.create_account("111", "acme", "an account")
Accounts and namespaces
Every named thing in euclid lives in an account and a namespace, and is unique only within that
pair: two namespaces may each have an orders queue, a suppliers table, a billing application
or an orders route, and they have nothing to do with each other. A bare name always means yours —
get_queue_ern("orders"), a bucket named in a deployment, a queue named in a grant — and the server
resolves it in the account the session logged into and the namespace it is scoped to. It cannot
reach into another namespace's resource of the same name.
That scope is the session's, and change_namespace moves it:
session.change_namespace("development")
orders = session.eqs().get_queue_ern("orders") # development's orders queue
Two consequences worth knowing. Resources that run as processes — EAP applications and ETS transfer
servers — also carry a runtime_name, because a process, its socket and its log channel have no
namespace to live in and so cannot be keyed by an ID two namespaces may share; it is issued once and
never changes, so moving an application does not orphan what is already running. And for EAP,
changing an application's namespace is a move rather than a field change: the buckets and queues it
may reach are re-resolved in the namespace it moves to, and the move is refused if an application of
that ID already lives there.
The two calls that empty a whole namespace — eqs.purge_all_queues() and ens.purge_all_topics() —
default to the session's own, which is the scope the caller can see. Emptying the field asks for
every namespace of the account, which is a much larger thing to ask for, so it has a name:
from euclid.modules.eqs import EVERY_NAMESPACE
eqs.purge_all_queues() # this namespace
eqs.purge_all_queues(namespace=EVERY_NAMESPACE) # all of them, deliberately
The credentials cache
login() writes ~/.euclid/credentials and reads it back on the next call, so logging in twice
costs one round trip. It is the same file euclid-cli and euclid-jdk use, with the same field
names, so a login from any of the three is picked up by the others. EUCLID_CREDENTIALS_FILE
overrides the path, which is also how euclid hands a managed application its own credentials.
Pass use_cache(False) to force a fresh login and leave the file alone.
Signing
A login returns two credentials: a bearer token, and - when the user has one - an access key and
secret. By default a session signs with the access key when it has one and presents the token
otherwise, which is what AUTH_AUTO means. euclid accepts either for every action.
from euclid import AUTH_BEARER, AUTH_SIGNATURE, SigningScheme
session = Euclid.for_server(url).login("jens", "secret", auth=AUTH_BEARER)
Two signing schemes are implemented, both keyed by the same access key and secret:
| Scheme | Where the signature travels | Notes |
|---|---|---|
SigningScheme.SIGV4 |
Authorization, plus x-amz-date and x-amz-content-sha256 |
The default, and what euclid has understood from the start |
SigningScheme.RFC9421 |
Signature and Signature-Input, plus Content-Digest |
RFC 9421 HTTP Message Signatures, the standard scheme meant to replace it |
Both cover a fixed set of headers rather than a set the request declares: the method, path and
authority, the body digest, and the x-euclid-account-id, x-euclid-action, x-euclid-region,
x-euclid-target and x-euclid-user-id headers that carry what the request is asking for and on
whose behalf. euclid's server compares that list against its own for exact equality, so a signature
covering more, fewer, or the same components in another order is rejected. x-euclid-namespace is
not covered - a real gap rather than a simplification, and one that has to be closed on both
sides at once.
Verification is implemented too, not just signing, and is what the test suite's fake gateway uses:
from euclid.auth import SignableRequest, SigningScheme
request = SignableRequest("POST", "/")
request.headers_from(incoming_headers).set_body(incoming_body).set_scheme("https")
scheme = SigningScheme.of(request)
key_id = scheme.verify(request, lookup_secret) if scheme else None
TLS
https:// URLs are verified against the system trust store. A euclid deployment usually presents
its own certificate, so /etc/euclid/euclid_cert.crt is trusted alongside the system store when
that file exists - the same default euclid-cli --ca-cert uses. Point ca_cert_path elsewhere, or
pass verify(False) for a development server whose certificate nothing vouches for.
Errors
| Exception | Raised when |
|---|---|
EuclidAuthenticationError |
a login was refused |
EuclidServiceError |
a module refused or failed an action; carries target, action, status and reason |
EuclidError |
base class for both |
reason is the server's own message, pulled out of the {"error": "..."} body every euclid module
answers failures with.
Retries
Two, both narrow on purpose:
- A request that failed because the connection was closed while it sat idle is sent again once, on a fresh connection. Only failures that produced no response at all qualify.
- A 401 whose body says the credentials had expired is sent again once with rebuilt headers, if rebuilding them produces something different. A wrong password or a missing permission is answered once, as before.
For a process that outlives its token, set session.token_provider to something that re-reads the
credentials file; the retry then has a fresh token to use.
What EAM covers
| Method | Action |
|---|---|
list_users, register, delete_user |
users |
create_access_key, list_access_keys, delete_access_key |
the caller's own signing credentials |
create_user_group, list_user_groups, delete_user_group, add_user_to_user_group, remove_user_from_user_group |
groups |
create_account, list_accounts, delete_account |
accounts |
create_namespace, list_namespaces, delete_namespace, grant_namespace_access, revoke_namespace_access |
namespaces |
change_namespace |
which namespace this session is scoped to |
metrics |
EAM's own metrics |
call(action, payload) |
anything the server gained that this SDK has not wrapped yet |
Several of these are administrator-only server-side; session.is_admin says whether the logged-in
user is one, though the server enforces it regardless.
What ESM covers
session.esm() returns the storage client, on the session's credentials and namespace - and it
follows them, so a change_namespace between two calls scopes the second one. It is the same
client each time, which is what keeps an application that asks for it per operation to one
connection.
| Method | Action |
|---|---|
create_bucket, list_buckets, get_bucket_ern, get_bucket_size, rename_bucket, delete_bucket |
buckets |
add_bucket_tag, set_bucket_tag, delete_bucket_tag |
bucket tags |
set_bucket_internal |
whether a bucket is euclid's own plumbing, and so left out of listings |
enable_encryption, disable_encryption |
encryption at rest, under an EKM key |
put_object, get_object, upload_file, download_file |
an object's bytes |
list_objects, get_object_count, copy_object, move_object, rename_object |
objects |
delete_object, delete_objects, purge_bucket |
deleting them |
touch_object |
re-announcing objects to listeners that missed their creation |
add_object_attribute, set_object_attribute, list_object_attributes, delete_object_attribute |
user-defined attributes |
subscribe, unsubscribe, list_subscriptions, parse_bucket_event |
announcing a bucket's events to a queue or a topic |
metrics |
ESM's own metrics |
call(action, payload) |
anything the server gained that this SDK has not wrapped yet |
upload_file splits the file into 5 MiB parts and sends four at a time by default, reading it a
part at a time rather than into memory; download_file tries a single request first and falls back
to fetching parts when the server says the object is too large for one. Both retry a part that
failed transiently - a 5xx, or a request that got no answer - four times before giving up, because
a transfer is long and made of many steps and failing one of them throws away all the others.
Attributes belong on the write rather than on a call after it. Completing an upload is finished off in the background on the server, and the object row it writes at the end carries what the upload supplied, so an attribute added in between is overwritten and silently lost:
from euclid import Variant
esm.upload_file(bucket_ern, "2026/q3.pdf", "q3.pdf",
attributes={"tenant": "acme", "revision": 3, "checksum": Variant("binary", b"\x01\x02")},
system_attributes={"priority": "LOW"})
The two maps are not the same one. attributes are the caller's own, listed back by
list_object_attributes and meaningless to euclid; system_attributes are euclid's envelope,
which travel with the object across every hop. The one euclid acts on is priority: it is what
carries a producer's decision through a bucket into the queue subscribed to it. Plain Python values
are tagged with the type euclid stores them under, so Variant is only needed for a tag other than
the obvious one - a 32-bit int rather than a long, say.
Four actions carry an object's bytes rather than JSON - put-object, get-object, upload-part
and download-part - and those present the session's bearer token rather than a signature, which is
what euclid-cli and euclid-jdk do for the same four, so all three clients write objects the same
way. A session created with auth=AUTH_SIGNATURE signs them anyway: it asked not to be handed a
token quietly, and hashing raw bytes is exact here in a way it is not in every language.
What EQS covers
session.eqs() returns the queue client.
| Method | Action |
|---|---|
create_queue, list_queues, get_queue_ern, get_queue_metadata, delete_queue |
queues |
add_queue_tag, set_queue_tag, delete_queue_tag |
queue tags |
stop_queue, start_queue, set_queue_visibility, purge_queue, purge_all_queues |
what a queue does |
send_message, receive_messages, receive_all_messages, delete_message, delete_message_by_id |
messages |
list_messages, get_message_count, get_message_metadata, set_message_visibility |
inspecting them |
get_message_attribute, set_message_attribute |
message attributes |
redrive_dlq |
moving messages out of a dead letter queue |
metrics, as_internal |
EQS's own metrics, and marking a caller's traffic as euclid's own |
call(action, payload) |
anything the server gained that this SDK has not wrapped yet |
Receiving is a lease, not a read. A message a consumer takes is invisible to every other consumer
until its visibility timeout expires, and delete_message(receipt_handle) is what says the work was
done - so the delete belongs after the work, since a consumer that dies instead simply stops holding
the lease and the message comes back. list_messages is the other thing: it reads a queue without
touching it, and nothing it returns can be deleted by receipt handle.
receive_messages(ern, wait_time=20) is a long poll, and the waiting is the server's: it holds the
request open until a message lands or the window runs out, so an idle queue costs one request rather
than one per poll tick and a message comes back the instant it is sent. That request gets its own
timeout - the session's is sized for an answer that comes straight back. Two cases the client
handles for you: with no wait asked for, an empty queue costs no receive at all (a receive is a
write); and a server with no long-poll slot free answers immediately rather than queueing behind the
waiters, which is a pause and another ask rather than an empty result.
as_internal() returns a second view whose requests carry x-euclid-internal. Some calls observe
the system rather than use it - the same get_message_count is a user's question one moment and a
metric collector's poll the next - and euclid scales a module on the traffic it sees, so
instrumentation that polls every few seconds would otherwise keep a pool permanently awake.
What ENS covers
session.ens() returns the topic client.
| Method | Action |
|---|---|
create_topic, list_topics, get_topic_ern, get_topic_metadata, delete_topic |
topics |
add_topic_tag, set_topic_tag, delete_topic_tag |
topic tags |
stop_topic, start_topic |
holding delivery, and letting it go again |
set_topic_retention |
how long a published message is kept |
publish_message, list_messages, get_message_count, purge_topic, purge_all_topics |
messages |
get_message_attribute, set_message_attribute |
message attributes |
subscribe, unsubscribe, list_subscriptions |
delivering a topic's messages to a queue |
call(action, payload) |
anything the server gained that this SDK has not wrapped yet |
The difference from EQS is what happens to a message once it is there. A queue holds a message until a consumer takes it; a topic hands each message to every subscriber and keeps it as a record of having done so. So there is no receive and no receipt handle here - a subscriber consumes from its own queue, which is where the message was delivered:
ens.subscribe(topic_ern, eqs.get_queue_ern("orders"))
ens.publish_message(topic_ern, '{"order": 17}', priority="HIGH")
Stopping a topic holds delivery without refusing publishers. A stopped topic still accepts what
is published to it and keeps it; it simply does not fan it out — which is what a subscriber being
redeployed, or a downstream system taken down for the evening, actually wants. start_topic hands
over the whole backlog, oldest first, as part of the call, and says how much went:
ens.stop_topic(topic_ern) # publishers carry on; delivery does not
...
released = ens.start_topic(topic_ern).released # the backlog goes out here, oldest first
That backlog is real work: a topic that collected a fortnight of traffic is a fortnight of fan-out
in that one call. The server pages through it and marks each message as it goes, so a start that is
interrupted has delivered a prefix rather than nothing and running it again resumes.
get_topic_metadata(...).held is how much is waiting, which is what says whether starting it is a
moment's work.
Retention is worth setting. A topic is fanned out at publish time, so nothing ever consumes its messages and nothing else removes them — without a period the collection only grows, and every topic shares it:
ens.set_topic_retention(topic_ern, 7 * 24 * 3600) # a week, in seconds
ens.set_topic_retention(topic_ern, INSTALLATION_RETENTION) # 0: follow the installation's own
Zero is not "keep nothing" but "whatever euclid.modules.ens.retention-period says", followed as it
changes rather than frozen on the day the topic was made. The change applies to messages published
afterwards; the ones already stored keep the expiry they were stamped with. A negative period raises
ValueError here rather than costing a round trip to be refused.
Two field names are the server's own asymmetry rather than a typo here: a message attribute travels
as key throughout ENS and as name in most of EQS, and this SDK reproduces both rather than
papering over either, so a request built from this documentation matches what euclid-cli sends.
What EES covers
session.ees() returns the event client — euclid's event bus, for consumers that are not euclid
modules.
| Method | Action |
|---|---|
subscribe_events, unsubscribe_events, list_subscriptions |
what a name is interested in |
receive_events, ack_event, ack_events |
claiming events and deleting them |
metrics |
EES's own metrics |
call(action, payload) |
anything the server gained that this SDK has not wrapped yet |
A subscriber registers a durable name and pulls. Nothing is pushed and no queue has to be created:
receive_events claims what is waiting and ack_events deletes it, and an event claimed but never
acknowledged becomes claimable again when its visibility timeout runs out — so a consumer that dies
mid-work loses nothing, and the acknowledgement belongs after the work rather than before it.
The name decides fan-out. Two instances of one application share a name and compete for each event; two different applications use different names and each get their own copy.
from euclid.modules.ees import OBJECT_CREATED
ees.subscribe_events("invoice-indexer", [OBJECT_CREATED], {"prefix": "invoices/2026/"})
while True:
for event in ees.receive_events("invoice-indexer", wait_time=20).events:
index(event["bucketErn"], event["key"]) # an Event reads its payload like a mapping
ees.ack_event("invoice-indexer", event.event_id)
The filter is evaluated where the event is published rather than where it is read, so a subscriber
accumulates what it asked for rather than everything of that type in the installation. ESM's object
events — OBJECT_CREATED, OBJECT_UPDATED, OBJECT_DELETED — carry a flat payload of strings,
numbers and booleans precisely so a filter can match it by equality: {"bucketErn": ...} for one
bucket, {"prefix": "invoices/2026/"} for one "directory" (a key is a path by convention only, and
prefix is that convention spelled out by the server rather than by every subscriber), and
{"directory": False} to skip directory markers.
An event's payload and a subscription's filter stay plain dictionaries. Their shape belongs to
the event type, so parsing them would mean this SDK knowing what every module publishes — and going
out of date the first time one of them adds a field.
receive_events long-polls, with a request timeout of its own; wait_time is clamped by the server
to 20 seconds, and a server with no long-poll slot free answers at once with whatever is there,
which a consumer loop simply asks about again.
This is the richer, server-side-filtered path. ESM's own subscribe puts a notification into a
queue or topic instead — see parse_bucket_event — and the esm.subscription.* events are the
plumbing behind it rather than something to subscribe to.
What EKM covers
session.ekm() returns the key client.
| Method | Action |
|---|---|
create_key, list_keys, set_key_description, add_key_tag, delete_key_tag |
keys |
revoke_key, delete_key |
ending one |
encrypt, decrypt |
using one |
import_certificate, create_certificate, get_certificate, list_certificates, delete_certificate |
certificates |
call(action, payload) |
anything the server gained that this SDK has not wrapped yet |
Key material never leaves the server: encrypt and decrypt send the bytes to the key rather than
fetching the key to the bytes, and nothing in euclid.dto.ekm has a field for it. encrypt returns
IV || ciphertext || tag, which is exactly what decrypt takes back.
A key is named two ways and they are not interchangeable — the server's own split, which the SDK
reproduces. The ID (Key.name, what create_key returns) encrypts, decrypts and deletes; the ERN
revokes, describes and tags.
Ending a key has two forms, and the difference is what happens to what it wrote. revoke_key stops
it encrypting anything further and still decrypts, so it is what to reach for when the key should no
longer be used but the data under it is still wanted. delete_key schedules it — seven days by
default — and when that date passes, everything under it becomes unreadable: a bucket's objects, a
secret's value. That window is the only chance anybody gets to notice, which is why the key is not
deleted outright.
key = ekm.create_key(description="customer exports") # AES-256 unless told otherwise
sealed = ekm.encrypt(key.name, b"account 4711")
assert ekm.decrypt(key.name, sealed) == b"account 4711"
create_certificate generates a self-signed one, for an installation that has to serve HTTPS before
anybody has bought it a real one — Certificate.generated says nobody vouched for it.
import_certificate takes a real one plus the private key that proves it, and the server checks the
two against each other rather than letting a mismatch surface later as a handshake that fails for
every caller. The private key goes in and is never handed back.
What ESS covers
session.ess() returns the secret client.
| Method | Action |
|---|---|
create_secret, get_secret, list_secrets, delete_secret |
secrets |
rotate_secret, update_secret |
changing one |
add_secret_tag, delete_secret_tag |
secret tags |
metrics |
ESS's own metrics |
call(action, payload) |
anything the server gained that this SDK has not wrapped yet |
A value is encrypted with EKM before it is stored, so a secret's life is tied to a key's: deleting
that key is what makes the value unrecoverable, whatever ESS still says about the secret.
create_secret(..., key_ern=...) is how a set of secrets is made revocable together, and
update_secret(name, key_ern=...) re-encrypts one onto another key — how a secret is moved off a key
that is being retired.
Only get_secret returns a value; everything else answers with metadata alone, so a listing, a
rotation or a tag change can be logged without being the thing that leaks it:
password = ess.get_secret("db-password").value
ess.rotate_secret("db-password", new_password) # bumps version, records `rotated`
update_secret distinguishes a field being sent from a field being empty, because the server does:
leaving description as None leaves the stored one alone, while passing "" clears it - and the
same for value, since an empty string is a value somebody may legitimately store. An update that
names none of the three raises ValueError here rather than costing a round trip to be refused.
What EKV covers
session.ekv() returns the key-value client.
| Method | Action |
|---|---|
create_table, describe_table, list_tables, delete_table |
tables |
put_item, get_item, find_item, delete_item |
items, one at a time |
query |
the items of one partition, in sort-key order |
scan |
a table's items without regard to their key |
metrics |
EKV's own metrics |
call(action, payload) |
anything the server gained that this SDK has not wrapped yet |
A table name is unique within an account and a namespace: two namespaces may each have a suppliers
table, and their items are nothing to do with each other.
A table is keyed on one attribute or on two: a partition key that identifies an item, and optionally
a sort key that orders the items sharing a partition key - which is what makes a partition readable
as a range. Both have declared types (STRING, NUMBER, BINARY), fixed at creation, and the type
is what a comparison is made under: a NUMBER sort key orders 2, 9, 10, 100 rather than putting
"10" before "9".
An item's other attributes are free-form documents - scalars, lists, nested maps - and are not
declared anywhere. They are also not the tagged Variant that EQS, ENS and ESM attributes use: EKV
stores what JSON can express.
from euclid.modules.ekv import GE, NUMBER
ekv.create_table("sessions", "userId", sort_key="startedAt", sort_key_type=NUMBER)
ekv.put_item("sessions", {"userId": "jens", "startedAt": 1757462400, "host": "laptop"})
for item in ekv.query("sessions", "jens", GE, 1757462400).items:
print(item["host"]) # an Item reads like a mapping
Three things this SDK does rather than pass straight through:
put_itemreplaces rather than merges, so changing one field means reading the item, changing it and writing the whole thing back.Itemtherefore keeps the server's_createdand_modifiedout of.attributesand exposes them as.createdand.modified- left in, they would be written back as two attributes of the caller's own, and stick.get_itemraises on a miss (HTTP 404), because "there is no such item" and "here is an item with nothing in it" are different.find_itemis its miss-tolerant twin, returningNone—dict.getto itsdict[...]. Only a 404 becomesNone; a malformed key still raises.queryalways sendsforward, since the server reads an absent flag as descending rather than as unspecified. Asking forBETWEENwithout both bounds raisesValueErrorhere rather than costing a round trip to be refused.
query addresses a partition by key; scan reads the table. The second is fine for a small table
or an export and is the wrong tool for a lookup - ScanResult.total says how much there is to get
through.
What EAP covers
session.eap() returns the application client. Administrator-only server-side, all of it.
| Method | Action |
|---|---|
create_application, update_application, redeploy_application, delete_application |
deploying |
start_application, stop_application |
running |
list_applications, get_application |
what is deployed, and what is answering |
set_log_level, reset_log_level |
what one application logs at |
metrics |
EAP's own metrics |
call(action, payload) |
anything the server gained that this SDK has not wrapped yet |
An application is deployed from an artifact already in a bucket — ESM puts it there, EAP names it — and the deployment says which buckets and queues it may reach. euclid grants those to the identity it runs as: a technical principal it creates for the application unless one is named, with no password, no login and one access key, so nothing an application leaks is a person's credential.
from euclid.modules.eap import JAVA
eap.create_application("order-service", JAVA, bucket="artifacts",
artifact="order-service-1.4.0.jar", queues=["orders"],
min_instances=2, max_instances=5)
eap.start_application("order-service")
A few things this SDK reproduces rather than smooths over:
- Deploying names things; the answer describes ERNs. A deployment takes a
bucketname and anartifactkey, and theApplicationthat comes back hasbucket_ernandartifact_key; thebucketsandqueuesgranted come back resolved intoresources, in the namespace the application is deployed into. Names are what an operator has in hand, ERNs are what euclid stores. - An application is identified by its namespace as well as its ID.
Application.namespacesays which one — two namespaces may each deploy abilling— andruntime_nameis what the process, its socket and its log channel are named after, since none of those has a namespace to live in. Naming anamespaceinupdate_applicationis a move: the grants are re-resolved there, and it is refused if that namespace already has an application of this ID. desired_stateis what was asked for andstateis what is running.start_applicationchanges the first and the manager acts on it, so the application in the answer is usually stillSTOPPED. The two differing is an application starting up; the two differing for long is one that cannot.update_applicationsends only what it names — butbucketsandqueuesare re-resolved together whenever either is named, so the SDK leaves both out unless you pass them. Passing either alone revokes what the other used to grant; passing both empty revokes everything, deliberately.
redeploy_application is what a new build of the same application usually wants: the artifact
defaults to the one already deployed and the version to whatever the artifact's name says. A
redeploy that would change neither the version nor the checksum is refused with HTTP 409 — it would
restart the instances for nothing, and usually means the new artifact never reached the bucket.
What EAG covers
session.eag() returns the API gateway client. Every action here is administrator-only
server-side; session.is_admin says whether the logged-in user is one, though the server enforces
it regardless.
| Method | Action |
|---|---|
create_route, create_module_route, get_route, list_routes, delete_route |
routes |
update_route, set_route_active |
changing one |
list_listeners |
the ports the gateway answers on, and whether it is answering |
metrics |
EAG's own metrics |
call(action, payload) |
anything the server gained that this SDK has not wrapped yet |
A route ID is unique within an account and a namespace rather than across the installation, so two
namespaces may each publish an orders route - and namespace on a create says which one it
publishes in, defaulting to the session's.
A route publishes a path prefix and says where everything beneath it goes: to an application euclid
runs, or to one action of a euclid module. It is one or the other, never both and never neither -
those are reached in entirely different ways, and a route naming both would leave which one wins up
to the proxy. This SDK raises ValueError for that rather than costing a round trip to be refused.
from euclid.modules.eag import EUCLID_AUTH
eag.create_route("orders", "/api/orders", "order-service",
methods=["GET", "POST"], authentication=EUCLID_AUTH)
# The way in for something outside euclid that needs euclid itself.
eag.create_module_route("login", "/euclid/login", "eam", "login")
Module routes are what a browser needs to log in before it can call anything: without one, a front end talks to the API gateway for the application and to euclid's own gateway for its credentials - two ports, two origins, and CORS between them.
authentication is NO_AUTH (proxied as it arrives, the application enforcing whatever it
requires), EUCLID_AUTH (a euclid credential — token, RFC 9421 signature or SigV4 — verified before
anything is forwarded), or BASIC_AUTH (HTTP Basic against a euclid user's password, for the
callers a euclid credential does not suit).
set_route_active(route_id, False) is how something stops being exposed in a hurry: the route stays
exactly as it was and comes back the same, which deleting and recreating it would not guarantee.
update_route changes only what it names, and namespace/region are left out of a create
entirely unless you name them — the server reads an empty string as the empty namespace rather than
as "unspecified", so sending one would scope the route to nothing.
list_listeners reports what the gateway was configured to serve and whether it is serving it. A
listener whose port was taken, or whose certificate could not be loaded, is still listed — it is the
one somebody is looking for — and serving is what says whether anything is bound. An HTTPS
listener's certificate arrives flat, as a dozen certificate* fields, which the SDK gathers into
Listener.certificate (or None where there is none to report):
for listener in eag.list_listeners().listeners:
seal = listener.certificate
print(listener.port, listener.protocol,
"self-signed" if seal and seal.generated else "issued" if seal else "no certificate")
What ETS covers
session.ets() returns the transfer server client. Administrator-only server-side, all of it.
| Method | Action |
|---|---|
create_server, update_server, get_server, list_servers, delete_server |
definitions |
start_server, stop_server |
running |
metrics |
ETS's own metrics |
call(action, payload) |
anything the server gained that this SDK has not wrapped yet |
ETS never speaks FTP or SFTP itself. It owns the definitions — which protocol, which port, which EAM users and groups may log in, which ESM bucket the files really live in — and starting one is nothing more than writing a desired state onto a definition; euclid's manager turns that into a running process, which reads its own definition back from here.
So a file uploaded over FTP is an object in a bucket, with the events and the lifecycle every other object has. This is a protocol somebody's existing tooling already speaks, put in front of storage, rather than a second place files live.
from euclid.modules.ets import SFTP
ets.create_server("partner-drop", bucket="invoices", port=2222, protocol=SFTP,
user_groups=["partners"], home_directory="incoming/")
ets.start_server("partner-drop")
home_directory is the key prefix a logged-in user lands in, which is what lets one bucket serve
several servers without either seeing the other's files. host_key is SFTP's, generated on first
start when left empty — set it only to keep a key clients already trust. pasv_min/pasv_max are
FTP's passive range, which whatever sits in front of euclid has to let through as well as the
control port.
TransferServer.namespace says which namespace a server was defined in — a server_id is unique
within an account and a namespace, not across the installation — and runtime_name is what its
process, socket and log channel are named after, for the same reason EAP has one.
As in EAP, desired_state is what was asked for and state is what is observed, and
update_server sends only what it names — a named list replaces the stored one rather than adding
to it. The protocol is not updatable and so is not a parameter: which one a server speaks decides
which process runs it, so changing it would be a different server. A running server keeps running on
its old definition until it is restarted, since the process reads it once at startup.
Writing a module of your own
euclid.ModuleClient is what the three above are built on: it knows euclid's request shape, the
session's credentials and how to turn a refusal into a EuclidServiceError, and nothing else. A
module this SDK has not wrapped needs a target and its actions:
from euclid import ModuleClient
class Ees(ModuleClient):
target = "ees"
def list_rules(self, prefix=""):
return self.call("list-rules", {"prefix": prefix})
Ees(session).list_rules()
Set byte_actions on the subclass for any action of that module whose body is raw bytes rather than
JSON; _post_bytes sends one, and those actions then authenticate the way ESM's and EKM's do.
Development
pip install -e ".[dev]"
pytest
The suite runs against a fake euclid gateway (tests/fake_gateway.py) that authenticates requests
with the same rules Core::HttpActionServer::Authenticate applies, so a client that signs one
thing and sends another fails there rather than in production. The SigV4 canonicalisation is
additionally pinned by AWS's own published test vectors, which is what makes this SDK, euclid's C++
and euclid-jdk agree rather than merely each agree with itself.
Behind that gateway sit two stand-ins that do the work rather than stub it. tests/fake_storage.py
assembles an upload's parts in part order and hands back the byte range a download asked for;
tests/fake_queues.py leases messages out with receipt handles and either honours a long poll or
declines it the way a server short of slots does. Both are deliberate: the part of a multipart
transfer a stubbed test cannot see is exactly the part that corrupts an object, and a queue whose
server always answers immediately cannot tell a client that abandons a long poll from one that
does not.
Releasing
Bump version in pyproject.toml and __version__ in src/euclid/__init__.py - they have to
agree, and .github/workflows/publish.yml refuses to publish if they and the tag do not - then tag
and push:
git tag -a v0.3.0 -m "euclid-pdk 0.3.0"
git push origin main v0.3.0
The tag runs the tests again (the test workflow triggers on branches, so a tag push would otherwise
run nothing), builds the wheel and the sdist, checks them with twine check --strict, and uploads
to PyPI. workflow_dispatch does the same for whatever main says, which is what a version whose
tag predates this workflow needs.
Publishing uses PyPI's trusted publishing rather than an API token: PyPI verifies the workflow's
own OIDC identity, so there is no secret to rotate or leak. It has to be configured once, under the
project's Publishing settings on PyPI - owner jensvogt, repository euclid-pdk, workflow
publish.yml, environment pypi - and for a project that does not exist there yet, as a pending
publisher.
Licence
Apache License 2.0.
Metadata
Release files for euclid-pdk 0.4.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 | |
|---|---|---|---|---|
| euclid_pdk-0.4.0.tar.gz | 173.3 kB | With provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| euclid_pdk-0.4.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 304.1 kB
Release files / euclid_pdk-0.4.0.tar.gz
| Download URL | euclid_pdk-0.4.0.tar.gz |
|---|---|
| Size | 173.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
47f5a2e5fb279f4bb3f723f998e8e3315d1e94546867a6145a60a98148ac9d19
|
|
BLAKE2b-256 checksum How to use checksums |
72e69d6fc8fa25578f3cfc20c46f9e8fe9acdd7bbbfdfe8e4ed8ecda45fdc20b
|
| 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 Sep 11, 2026.
Transparency logRelease files / euclid_pdk-0.4.0-py3-none-any.whl
| Download URL | euclid_pdk-0.4.0-py3-none-any.whl |
|---|---|
| Size | 130.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
e0ad016138b47762b38625e9c3150cb92e43ca4b78121bf31fe585c328847ede
|
|
BLAKE2b-256 checksum How to use checksums |
9729ac0461f257215d688b5b2a04d6abffcc97199a2b4cd61a1978d1b1f7a786
|
| 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 Sep 11, 2026.
Transparency logSecurity for euclid-pdk 0.4.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
All 2 files in this release have been uploaded with provenance metadata.
Attestation for euclid_pdk-0.4.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 Sep 11, 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.