A stored method belongs to the organization that holds it, and access to it comes from membership in that organization. copy_method makes a new method in another organization the caller may change methods in, holding the original's draft, its form inputs and its saved examples, and leaves the original as it was. It is how a method reaches a team from the personal workspace that holds it, how it moves from a team into the caller's personal workspace, and, with the method's own organization as the destination, how a method is duplicated. list_method_copies lists the copies made of a method, so a surface can say where they went. The client reaches them on the hosted API's POST /v1/methods/{method_id}/copy and GET /v1/methods/{method_id}/copies, and the models are in pipelex_sdk/product_models.py. This page is the twin of @pipelex/sdk's docs/method-copy.md.
import uuid
from pipelex_sdk.product_models import MethodCopied, MethodCopyRefused
method = await client.get_method("mt_abc123")
result = await client.copy_method(
method.method_id,
destination_org_id="org_team",
expected_draft_updated_at=method.updated_at,
idempotency_key=str(uuid.uuid4()),
)
match result:
case MethodCopied():
print(f"copied as {result.method.method_id} in {result.method.org_id}")
case MethodCopyRefused():
for reference in result.references:
print(f"{reference.path} names {reference.uri}")| Method | Route | Answer |
|---|---|---|
copy_method(method_id, *, destination_org_id, name=None, expected_draft_updated_at=None, idempotency_key=None) |
POST /v1/methods/{id}/copy |
A MethodCopyResult, discriminated on outcome: MethodCopied or MethodCopyRefused. |
list_method_copies(method_id, *, limit=None, cursor=None) |
GET /v1/methods/{id}/copies |
One MethodCopyPage, items and next_cursor, of MethodCopyMarker rows. limit is 1 to 100, 50 by default. |
Both take a bare catalog id, as every method route does, and refuse a suffixed one (mt_…@3) with a RequestArgumentError before anything is sent. Every argument but the method id is keyword-only.
The copy's body is closed. The client sends destination_org_id and, when given, name and expected_draft_updated_at, and nothing else; a None reads as not given. destination_org_id is the canonical id of the destination, as a membership's org_id gives it; name is the copy's name, the original's when it is left out; expected_draft_updated_at is the original's draft token the caller last saw (MethodData.updated_at), echoed verbatim. A destination_org_id that is not a non-empty str, and a name or a token given that is not a str, are refused with a RequestArgumentError before any request, its refused_argument naming the argument.
Only a web session may copy. A Pipelex API key belongs to one organization and cannot speak for another, so the platform refuses a copy sent with one 403 auth.forbidden, whatever its role. The listing is a read, open to every member of the method's organization and to every credential.
| Part of the method | In the copy |
|---|---|
| The draft's files | The same tree, held in the destination, so the copy's draft_digest equals the original's; a draft holding no files copies to an empty draft, its draft_digest None |
name |
The original's, unless the request names another |
description |
The original's, which comes from the same tree |
input_data |
The original's form inputs, every stored file they name in the original's organization copied into the destination |
| Saved examples | Every example, in its order, each with a new id, at input_revision 1, with no latest run and the copier as its author, its inputs' stored files copied as the form inputs' are |
| Published versions | None: the copy starts unpublished, its latest_version and latest_published None, and its own publish makes version 1 |
| Runs | None: a run stays in the organization that ran it and was billed for it |
method_id, org_id, created_by_user_id, created_at |
A new id, the destination, the copier and the moment of the copy |
copied_from |
{ org_id, method_id, draft_digest } of the original, as it stood at the copy, its draft_digest None when the original's draft held no files |
The copy takes the draft, never the latest published version. The draft is what the person sees in the editor, and the copy goes through its own first publish anyway; an invalid draft is copied as it is, and the copy's publish refuses it as it would any draft. With expected_draft_updated_at, the copy takes exactly the draft the caller last saw: a draft that moved since is a 409 pipelex.catalog.update_conflict, and nothing is copied. A client that has just saved the editor's pending edits sends the token that save answered.
A stored file named by an input is copied. A pipelex-storage:// address resolves only inside the organization it names, so every such address in the original's organization, in the form inputs and in each example's inputs, is copied into the destination's assets/ under a new address, and the value is rewritten to it; a file named twice is copied once. A duplicate keeps every address of the organization as it is, an upload's above all, since the organization is the same, and copies only the files the original method's own runs stored, under pipelex-storage://<org_id>/<method_id>/…, which erasing the original would sweep away. An address of another organization, a malformed one and one whose file is gone are kept unchanged. The destination records each copied file among its files, attributed to the copier. stored_files_copied counts the distinct files copied.
The answer is a 200 discriminated on outcome, as publish_method's is, because a bundle the copy cannot rely on is a verdict about the method, not a failure of the request. Branch on the result's class or on outcome, never on the status:
| Model | outcome |
Members | When |
|---|---|---|---|
MethodCopied |
copied |
method, the copy, a MethodData with its copied_from; examples_copied; stored_files_copied |
The copy exists in the destination. |
MethodCopyRefused |
refused |
reason, MethodCopyRefusalReason.BUNDLE_NAMES_OTHER_STORAGE; message, a sentence for a person; references, every BundleStorageReference (path, uri) the destination cannot read and, in a duplicate, every one under the original's run files, ordered by path and then by reference |
A .mthds file of the draft names a stored file outside the destination, which every run there would refuse, or, in a duplicate, a file under the original method's own run files, which erasing the original would remove. Nothing was written. |
A duplicate is held to one more rule. Every reference to the original's organization is one the destination can read, the original's storage being the destination's, but a reference under the original method's own run files, pipelex-storage://<org_id>/<method_id>/…, names a file one of the original's runs stored, and erasing the original sweeps those away: every run of the duplicate would then fail to fetch it. So such a reference is refused too, with the same verdict and the same reason, beside any the destination cannot read. It is the rule the copy's inputs follow: an input naming such a file is re-homed, while a bundle naming one is refused, since the platform rewrites a copy's inputs but never its bundles. Every other reference of the organization, an upload under its assets/ or another method's run file, passes in a duplicate as it does for the original. A copy into another organization needs no such rule, since it already refuses every reference to the original's organization. The message says why for each kind of reference.
A refused copy is fixed in the original: the person removes or replaces each reference the verdict names, then copies again. The platform does not rewrite the bundle, since that would change the author's code and the tree's digest without asking them.
MethodCopyResultAdapter reads the answer: an outcome that is neither arm's, or a body breaking its arm, such as a copied answer without its method or a refused one whose references is not a list, is an answer the SDK cannot read, raised as an ApiResponseError whose problem is client.response_unreadable, with pydantic's refusal as its cause.
A non-2xx answer means no verdict could be produced, and none of them leaves anything a reader sees, but for the 503 whose detail names the copy (below). Each is an ApiResponseError whose problem is the platform's problem document; branch on problem.code and its retry, never on the status alone.
| Status | problem.code |
When |
|---|---|---|
403 |
auth.forbidden |
The request is not from a web session, or the caller's role changes no method, runner or a role the platform does not know, in the method's organization or in the destination: the role must change. |
404 |
pipelex.org.not_found |
The destination is one the caller does not belong to, one whose invitation they have not accepted, one still being set up, or one that does not exist. The platform answers them alike, so it never says whether an organization exists. |
404 |
pipelex.catalog.not_found |
An unknown method. The destination is judged first, so an unknown method in a destination the caller may not write to reads pipelex.org.not_found. |
409 |
pipelex.catalog.being_deleted |
The original's erasure has started. |
409 |
pipelex.catalog.update_conflict |
The draft moved since expected_draft_updated_at; location.field names that member. Read the method again and copy with the fresh token. |
409 |
The method's organization holds no record of the draft's tree, or no longer holds every file of it: its domain is runtime and its retry no, a stored record contradicting itself that neither the caller nor a retry can mend. No registered code names this refusal yet; it is not the request.idempotency_conflict of an idempotency key reused with other arguments (below). |
|
413 |
request.too_large |
A copied example, or the copy's row, does not fit in one stored item. The row keeps room for the summary a publish adds, so a name that would leave none is refused. |
422 |
request.invalid |
A body that does not validate, such as a name holding a lone surrogate. |
422 |
pipelex.catalog.copy_too_large |
The copy passes one of its bounds on the stored files it copies. Its problem's limit member, typed MethodCopyLimit, names the bound, max_stored_files or max_stored_bytes, its value and what the copy reached. Removing examples, or the files they hold, brings it under. |
502 |
runtime.unavailable |
The platform could not read the caller's memberships. Its retry is yes. |
500 |
pipelex.storage.not_configured |
The platform has no usable credentials for storage. Its retry is no. |
503 |
pipelex.storage.unavailable |
Storage failed a read, a copy or a write. Its retry is yes. |
503 |
runtime.unavailable |
The copy did not finish inside its deadline, a stored file it copied was written over meanwhile, or DynamoDB failed a read or a write. Its retry is yes. When DynamoDB failed both the write of the copy's row and the read that would confirm it, the detail names the copy's method id and says that a retry may make a second copy: the copy may stand in the destination, without the original's marker of it and without the destination's records of its files, so look for that id in the destination before copying again. |
MethodErrorCode in pipelex_sdk.errors names ORGANIZATION_NOT_FOUND and COPY_TOO_LARGE beside the other method codes, and errors.md gives the problem every refusal carries.
from pipelex_sdk.errors import ApiResponseError, MethodErrorCode
from pipelex_sdk.product_models import MethodCopyLimit
try:
await client.copy_method("mt_abc123", destination_org_id="org_team")
except ApiResponseError as exc:
match exc.problem.code:
case MethodErrorCode.COPY_TOO_LARGE:
limit = MethodCopyLimit.model_validate((exc.problem.model_extra or {})["limit"])
print(f"{limit.name}: {limit.reached} of at most {limit.value}")
case _:
raisecopy_method takes an idempotency_key, sent as the Idempotency-Key header of the copy and not sent at all when None. Generate one key per copy the caller means to make (a str(uuid.uuid4()) is a good one), and keep it, with the arguments unchanged, across that copy's retries. A key is 1 to 255 visible ASCII characters, ! to ~; anything else, an empty string included, is a RequestArgumentError before anything is sent, as a start's is. The key rides that one request only: the next request the same client sends carries none.
| The copy | The answer |
|---|---|
| The first under the key | The copy is made, or refused, and its answer is stored under the key for 24 hours, scoped to the caller and the method's organization, unless it is a 5xx or a refusal for its timing (408, 425 or 429). |
The same arguments under the same key, once the first was answered with a verdict, or with a 4xx refusal other than one for its timing |
The stored answer, and nothing more is copied: a copy whose answer was lost, as on a client timeout, makes one copy, not two, and a copy refused for its content is refused again. |
The same arguments under the same key, after a 5xx or a refusal for its timing, such as a 429 |
A fresh attempt: neither is stored, the key was freed, and the failed copy left nothing visible, but for the 503 whose detail names the copy, which may stand. |
| The same arguments under the same key, while the first is still being handled, or while its stored answer cannot be read | A 409 request.idempotency_key_in_use, retry yes, answered by the key and never stored: send it again under the same key after its retry_after. |
| Other arguments under the same key | A 409 request.idempotency_conflict, retry no. A changed copy, such as one with a fresh draft token, goes under a new key. |
Without a key, a copy whose answer was lost cannot be told from one that never ran, and sending it again may make a second copy. Even with one, two rare windows can make a second copy on a retry. A platform process can die after writing the copy and before storing its answer, a window the creation of a method shares. And the 503 whose detail names the copy released the key while the copy may stand, which is why it names the copy: look for that method id in the destination before copying again.
The original keeps a marker of each copy made of it, and list_method_copies pages them. A MethodCopyMarker holds copy_method_id, destination_org_id, copied_by_user_id and copied_at. The destination is named by its id alone, never by its name, so the listing tells no reader the name of an organization they do not belong to: a surface shows a destination's name from the reader's own memberships (list_memberships), and a copy in an organization the reader does not belong to stays an id. The rows come in the order of the copies' ids, which says nothing of when each was made; sort by copied_at to show them by date.
Pass the page's next_cursor back as cursor to continue; None is the last page. A method never copied lists an empty page, as does a copy, which names its original through copied_from and has no copies of its own. A marker stays when its copy is erased, and the copy then reads as not found. The marker is written once the copy exists, and a failure to write it does not fail the copy, so a copy can, rarely, be missing from the list. The original's erasure and its discard delete its markers with it.
The listing answers 404 pipelex.catalog.not_found for an unknown method, 409 pipelex.catalog.being_deleted while its erasure runs, and 422 request.invalid for a cursor it did not issue for that method or a limit outside 1 to 100. A page without its next_cursor, or whose rows break MethodCopyMarker, is an answer the SDK cannot read.
Every read of a method, get_method and the method of every write, carries copied_from: a MethodCopiedFrom (org_id, method_id, draft_digest) of the original on a copy, and None on a method created afresh or from a platform that predates copies. It is written once, when the copy is made, and never rewritten, so it names the draft the copy started from even after the original has changed or been erased; its draft_digest is None for a draft that held no tree. It holds opaque ids only, never a name, and the original's organization may be one the reader does not belong to. The list rows (MethodSummary) do not carry it.