feat(sdk): enforce s3:// URIs for string S3 params + ingestion (EL) docs (#9912)

* feat(pipelines): ingestion (EL) templates + docs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(pipelines): review nits — draft collision guard, template-mode selection reset, invariant test

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(pipelines): lead the insert menu with ingestion templates

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(pipelines): ingestion story as docs-only — drop editor template UI

The insert-menu template section mixed two selection grammars in one popover and confused more than it helped. The three E2E-verified example pipelines now live verbatim in docs/pipeline-ingestion.md; the Python bare-string S3 key fix in pipelineTemplates.ts stays.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(sdk): bare string S3 keys in py/ts clients + asset parsers

A plain string passed where an S3Object is expected is now a bare key in the default storage — previously the py client silently degraded it to s3="" (auto-generated key) and both asset parsers canonicalized it without the leading slash, splitting lineage. parseS3Object moves to s3Types.ts so it is unit-testable without the generated services. The pipeline template fix from the earlier commit is superseded (bare strings are the supported spelling again); docs examples flipped to bare keys.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(sdk): enforce s3:// URIs for string S3Object params

Bare strings now raise/throw with a hint pointing at the s3:///<key> spelling instead of being treated as keys (previous commit) or silently degrading to an empty key (original behavior). One string spelling everywhere: SDK calls, // on annotations, and DuckDB SQL all use s3:///<key>. TS regains the s3://-template-literal type; the asset parsers record no asset for a bare string (the call can only error); templates emit the URI form.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(pipelines): move ingestion (EL) guide to windmilldocs, keep design constraints

User-facing how-to (engine choice, cursor recipes, schema drift, worked examples) moves to windmilldocs core_concepts/63_pipelines (windmilldocs#1462); the repo keeps only the design constraints future feature work must not break, as a section of ducklake-materialization.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: regenerate system prompts after parse_s3_object docstring change

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(sdk): reject empty-key s3 URIs; align asset parsers with the runtime rule

Addresses CI review: s3:/// and s3://bucket/ now raise (an empty key would fall back to the auto-generated-key path the strict contract exists to prevent); the asset parsers' string branch applies the same valid-URI-with-non-empty-key rule so no R/W edge is recorded for a call that can only error (the generic URI-literal scan still records ambiguous access-None assets, by design); comments rephrased as current constraints per AGENTS.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-07-04 16:07:39 +00:00
committed by GitHub
parent 33521505db
commit 5ad2de91a2
19 changed files with 417 additions and 93 deletions
@@ -319,12 +319,24 @@ fn dict_str_value(dict: &rustpython_ast::ExprDict, name: &str) -> Option<String>
/// `write_s3_file` to a canonical asset path, mirroring `windmill-parser-ts-asset`:
/// `S3Object(s3="<key>", storage="<bucket>"?)` — or the equivalent dict literal —
/// maps to the URI `s3://<bucket>/<key>` (empty bucket for default storage, i.e.
/// `s3:///<key>`), and a bare `"s3://bucket/key"` string is passed through.
/// `s3:///<key>`), and a `"s3://bucket/key"` URI string is passed through.
/// String args mirror the runtime `parse_s3_object` contract exactly: only a
/// `s3://<storage>/<key>` URI with a non-empty key is valid — any other
/// string (bare key, `s3://x`, empty key) raises at run time, so recording an
/// edge for it would be a phantom node for a call that can only error.
/// The resulting URI is fed through `parse_asset_syntax` so the stored path
/// matches the TS object form and the `# on s3:///…` trigger form exactly.
fn s3_object_arg_path(expr: &Expr) -> Option<String> {
let uri = match expr {
Expr::Constant(ExprConstant { value: Constant::Str(s), .. }) => s.clone(),
Expr::Constant(ExprConstant { value: Constant::Str(s), .. }) => {
match s
.strip_prefix("s3://")
.and_then(|rest| rest.split_once('/'))
{
Some((_, key)) if !key.is_empty() => s.clone(),
_ => return None,
}
}
Expr::Call(call) => {
// `S3Object(...)` imported directly or as `wmill.S3Object(...)`
let func_name = call
@@ -378,6 +390,40 @@ def main():
);
}
#[test]
fn test_py_asset_parser_bare_string_no_asset() {
// A plain (non-`s3://`) string is rejected by the runtime
// `parse_s3_object`, so the parser must not record a phantom asset
// for a call that can only error.
let input = r#"
import wmill
def main():
wmill.write_s3_file("pipelines/etl/out.jsonl", b"")
"#;
let s = parse_assets(input).map(|o| o.assets);
assert_eq!(s.map_err(|e| e.to_string()), Ok(vec![]));
}
#[test]
fn test_py_asset_parser_invalid_uri_no_write_edge() {
// `s3://x` (no key part) and `s3://bucket/` (empty key) are rejected
// by the runtime `parse_s3_object` — same rule for the SDK-arg path:
// no R/W edge. The generic URI-literal scan may still record them as
// ambiguous (`access_type: None`) assets, like any `s3://…` string
// constant anywhere in a script.
let input = r#"
import wmill
def main():
wmill.write_s3_file("s3://broken", b"")
wmill.write_s3_file("s3://bucket/", b"")
"#;
let assets = parse_assets(input).expect("parse").assets;
assert!(
assets.iter().all(|a| a.access_type.is_none()),
"invalid URIs must not produce R/W edges: {assets:?}"
);
}
#[test]
fn test_py_asset_parser_write_s3_object_constructor() {
// The SDK signature is `write_s3_file(s3object: S3Object | str, ...)` and
@@ -341,12 +341,25 @@ fn object_str_prop(obj: &ObjectLit, name: &str) -> Option<String> {
/// `writeS3File` to a canonical asset path, mirroring the runtime
/// `parseS3Object`: an object `{ s3: "<key>", storage?: "<bucket>" }` maps to
/// the URI `s3://<bucket>/<key>` (empty bucket for default storage, i.e.
/// `s3:///<key>`), and a bare `"s3://bucket/key"` string is passed through.
/// `s3:///<key>`), and a `"s3://bucket/key"` URI string is passed through.
/// String args mirror the runtime `parseS3Object` contract exactly: only a
/// `s3://<storage>/<key>` URI with a non-empty key is valid — any other
/// string (bare key, `s3://x`, empty key) throws at run time, so recording an
/// edge for it would be a phantom node for a call that can only error.
/// The resulting URI is fed through `parse_asset_syntax` so the stored path
/// matches the `// on s3:///…` trigger form exactly.
fn s3_object_arg_path(arg: &Expr) -> Option<String> {
let uri = match arg {
Expr::Lit(Lit::Str(s)) => s.value.to_string(),
Expr::Lit(Lit::Str(s)) => {
let v = s.value.to_string();
match v
.strip_prefix("s3://")
.and_then(|rest| rest.split_once('/'))
{
Some((_, key)) if !key.is_empty() => v,
_ => return None,
}
}
Expr::Object(obj) => {
let key = object_str_prop(obj, "s3")?;
let storage = object_str_prop(obj, "storage").unwrap_or_default();
@@ -477,6 +490,42 @@ mod tests {
);
}
#[test]
fn test_ts_asset_parser_bare_string_no_asset() {
// A plain (non-`s3://`) string is rejected by the runtime
// `parseS3Object`, so the parser must not record a phantom asset for
// a call that can only error.
let input = r#"
import * as wmill from "windmill-client"
export async function main() {
await wmill.writeS3File("pipelines/etl/out.jsonl", "[]")
}
"#;
let s = parse_assets(input);
assert_eq!(s.map(|r| r.assets).map_err(|e| e.to_string()), Ok(vec![]));
}
#[test]
fn test_ts_asset_parser_invalid_uri_no_write_edge() {
// `s3://x` (no key part) and `s3://bucket/` (empty key) are rejected
// by the runtime `parseS3Object` — same rule for the SDK-arg path:
// no R/W edge. The generic URI-literal scan may still record them as
// ambiguous (`access_type: None`) assets, like any `s3://…` string
// constant anywhere in a script.
let input = r#"
import * as wmill from "windmill-client"
export async function main() {
await wmill.writeS3File("s3://broken", "[]")
await wmill.writeS3File("s3://bucket/", "[]")
}
"#;
let assets = parse_assets(input).expect("parse").assets;
assert!(
assets.iter().all(|a| a.access_type.is_none()),
"invalid URIs must not produce R/W edges: {assets:?}"
);
}
#[test]
fn test_ts_asset_parser_multiple_s3_object_writes() {
// Mirrors the f/km/r_seed shape: several direct object-form writes in
+38 -22
View File
@@ -1000,13 +1000,6 @@ async requestInteractiveSlackApproval({ slackResourcePath, channelId, message, a
*/
async requestInteractiveTeamsApproval({ teamName, channelName, message, approver, defaultArgsJson, dynamicEnumsJson, }: TeamsApprovalOptions): Promise<void>
/**
* Parse an S3 object from URI string or record format
* @param s3Object - S3 object as URI string (s3://storage/key) or record
* @returns S3 object record with storage and s3 key
*/
parseS3Object(s3Object: S3Object): S3ObjectRecord
setWorkflowCtx(ctx: WorkflowCtx | null): void
async sleep(seconds: number): Promise<void>
@@ -1076,6 +1069,17 @@ async parallel<T, R>(items: T[], fn: (item: T) => PromiseLike<R> | R, options?:
*/
async commitKafkaOffsets(triggerPath: string, topic: string, partition: number, offset: number,): Promise<void>
/**
* Parse an S3 object from URI string or record format
* @param s3Object - S3 object as URI string (\`s3://storage/key\`, \`s3:///key\`
* for the default storage) or record. Any other string throws rather than
* falling back to an auto-generated key: an auto key is requested by
* omitting the object, and a fallback would silently misplace the upload
* on any typo.
* @returns S3 object record with storage and s3 key
*/
parseS3Object(s3Object: S3Object): S3ObjectRecord
/**
* Create a SQL template function for PostgreSQL/datatable queries
* @param name - Database/datatable name (default: "main")
@@ -1760,13 +1764,6 @@ async requestInteractiveSlackApproval({ slackResourcePath, channelId, message, a
*/
async requestInteractiveTeamsApproval({ teamName, channelName, message, approver, defaultArgsJson, dynamicEnumsJson, }: TeamsApprovalOptions): Promise<void>
/**
* Parse an S3 object from URI string or record format
* @param s3Object - S3 object as URI string (s3://storage/key) or record
* @returns S3 object record with storage and s3 key
*/
parseS3Object(s3Object: S3Object): S3ObjectRecord
setWorkflowCtx(ctx: WorkflowCtx | null): void
async sleep(seconds: number): Promise<void>
@@ -1836,6 +1833,17 @@ async parallel<T, R>(items: T[], fn: (item: T) => PromiseLike<R> | R, options?:
*/
async commitKafkaOffsets(triggerPath: string, topic: string, partition: number, offset: number,): Promise<void>
/**
* Parse an S3 object from URI string or record format
* @param s3Object - S3 object as URI string (\`s3://storage/key\`, \`s3:///key\`
* for the default storage) or record. Any other string throws rather than
* falling back to an auto-generated key: an auto key is requested by
* omitting the object, and a fallback would silently misplace the upload
* on any typo.
* @returns S3 object record with storage and s3 key
*/
parseS3Object(s3Object: S3Object): S3ObjectRecord
/**
* Create a SQL template function for PostgreSQL/datatable queries
* @param name - Database/datatable name (default: "main")
@@ -2612,13 +2620,6 @@ async requestInteractiveSlackApproval({ slackResourcePath, channelId, message, a
*/
async requestInteractiveTeamsApproval({ teamName, channelName, message, approver, defaultArgsJson, dynamicEnumsJson, }: TeamsApprovalOptions): Promise<void>
/**
* Parse an S3 object from URI string or record format
* @param s3Object - S3 object as URI string (s3://storage/key) or record
* @returns S3 object record with storage and s3 key
*/
parseS3Object(s3Object: S3Object): S3ObjectRecord
setWorkflowCtx(ctx: WorkflowCtx | null): void
async sleep(seconds: number): Promise<void>
@@ -2688,6 +2689,17 @@ async parallel<T, R>(items: T[], fn: (item: T) => PromiseLike<R> | R, options?:
*/
async commitKafkaOffsets(triggerPath: string, topic: string, partition: number, offset: number,): Promise<void>
/**
* Parse an S3 object from URI string or record format
* @param s3Object - S3 object as URI string (\`s3://storage/key\`, \`s3:///key\`
* for the default storage) or record. Any other string throws rather than
* falling back to an auto-generated key: an auto key is requested by
* omitting the object, and a fallback would silently misplace the upload
* on any typo.
* @returns S3 object record with storage and s3 key
*/
parseS3Object(s3Object: S3Object): S3ObjectRecord
/**
* Create a SQL template function for PostgreSQL/datatable queries
* @param name - Database/datatable name (default: "main")
@@ -4367,7 +4379,11 @@ def get_state_path() -> str
# Parse resource syntax from string.
def parse_resource_syntax(s: str) -> Optional[str]
# Parse S3 object from string or S3Object format.
# Parse S3 object from a \`s3://<storage>/<key>\` URI string (\`s3:///<key>\`
# for the default storage) or S3Object format. Any other string raises
# rather than falling back to an auto-generated key: an auto key is
# requested by omitting the object, and a fallback would silently misplace
# the upload on any typo.
def parse_s3_object(s3_object: S3Object | str) -> S3Object
# Parse variable syntax from string.
+39 -1
View File
@@ -2,7 +2,10 @@
Design sketch for "managed, versioned, incremental" assets built on the
DuckLake substrate. This is a companion to [`pipelines-vs-dbt.md`](./pipelines-vs-dbt.md)
and extends its **Path C (hybrid, partition-first)** recommendation. The new
and extends its **Path C (hybrid, partition-first)** recommendation. For the
extract-load side that feeds these materializations, see §"Ingestion (EL)"
at the end of this doc (user-facing guide: windmilldocs
`core_concepts/63_pipelines` → "Ingestion (EL)"). The new
contribution here is leveraging DuckLake's snapshot/time-travel layer, which
the earlier doc's incremental deep-dive did not use. The annotation grammar is
reconciled with that doc — `// partitioned` + `// unique_key` + `// append`
@@ -465,3 +468,38 @@ forces"; DuckLake-specific:
rejects anything else at deploy with a clear error pointing to
`// materialize manual`. The classifier (`sql_materialize.rs`) is the single
source of truth.
## Ingestion (EL): the sanctioned entry-node shape
Design constraints for how external data enters the lake — the user-facing
how-to (extract-engine choice, cursor recipes, schema-drift handling, worked
examples) lives in windmilldocs `core_concepts/63_pipelines` → "Ingestion
(EL)"; this section records only what future feature work must not break.
- **`// materialize` is DuckDB-only** (deploy-rejected elsewhere, managed and
`manual` alike — `windmill-api-scripts/src/scripts.rs`), and the SDK
materialize helpers (`upsert_partition` / `upsertPartition`) build their SQL
inside the SDK, so the asset parsers cannot see the write. A polyglot node
that "writes the lake directly" therefore deploys with **no output edge** —
breaking lineage, cascade scheduling, and the backfill UI's producer lookup.
- **The sanctioned polyglot shape is two scripts**: an entry node that lands
the raw batch as an object in workspace storage (`write_s3_file` — a
parser-visible write), and a DuckDB loader (`-- on s3:///<key>` +
`-- materialize`) that the asset dispatcher re-runs per batch. The landing
object is the seam; splitting E from L also keeps row work vectorized and
gives the load the full engine treatment (strategies, snapshots, partition
grid, `// data_test`).
- **One string spelling: `s3:///<key>`.** SDK string params are strictly
`s3://…` URIs with a non-empty key; anything else raises (clients >
1.746.0 — older clients silently upload such strings to an auto-generated
`windmill_uploads/…` name, so keep URIs in code that must run on them).
The same URI is what
`// on` annotations and DuckDB SQL take, and all forms (URI, `{s3}` object,
`S3Object(s3=…)`) canonicalize to path `/<key>`. The asset parsers record
**no asset** for a bare-string SDK argument (the call can only error at
run time) — keep `parse_s3_object` (py client), `parseS3Object`
(ts client, `s3Types.ts`) and both `s3_object_arg_path` parsers in lockstep
when touching any of them.
- DuckDB workers load no ICU extension: bare `TIMESTAMPTZ - INTERVAL`
arithmetic binder-errors. Incremental-pull SQL casts both sides
(`updated_at::TIMESTAMP > now()::TIMESTAMP - INTERVAL 7 DAY`).
@@ -391,8 +391,11 @@ function bodyTs(ctx: TemplateContext): string {
if (!input) return ''
switch (input.kind) {
case 's3object':
// `s3:///<key>` URI — one spelling shared with the `// on
// s3:///…` annotation form (the object literal `{ s3: <key> }`
// is equivalent).
return [
` const buf = await wmill.loadS3File({ s3: ${JSON.stringify(s3Key(input.path))} })`,
` const buf = await wmill.loadS3File(${JSON.stringify(`s3:///${s3Key(input.path)}`)})`,
` const rows = JSON.parse(new TextDecoder().decode(buf))`,
``
].join('\n')
@@ -418,9 +421,10 @@ function bodyTs(ctx: TemplateContext): string {
switch (outputKind) {
case 's3_parquet':
case 's3_object':
// `s3:///<key>` URI — see the loadS3File note above.
return [
` const payload = new TextEncoder().encode(JSON.stringify(rows))`,
` await wmill.writeS3File({ s3: ${JSON.stringify(s3Key(output.path))} }, payload)`
` await wmill.writeS3File(${JSON.stringify(`s3:///${s3Key(output.path)}`)}, payload)`
].join('\n')
case 'datatable': {
const dbName = output.path.split('/')[0] ?? 'main'
@@ -474,8 +478,11 @@ function bodyPython(ctx: TemplateContext): string {
if (!input) return ''
switch (input.kind) {
case 's3object':
// `s3:///<key>` URI — SDK string params must be s3:// URIs
// (bare keys are rejected), and this form matches the
// `# on s3:///…` annotation spelling.
return [
` buf = wmill.load_s3_file(${JSON.stringify(s3Key(input.path))})`,
` buf = wmill.load_s3_file(${JSON.stringify(`s3:///${s3Key(input.path)}`)})`,
` import json; rows = json.loads(buf.decode("utf-8"))`
].join('\n')
case 'datatable':
@@ -498,9 +505,10 @@ function bodyPython(ctx: TemplateContext): string {
switch (outputKind) {
case 's3_parquet':
case 's3_object':
// `s3:///<key>` URI — see the load_s3_file note above.
return [
` import json`,
` wmill.write_s3_file(${JSON.stringify(s3Key(output.path))}, json.dumps(rows).encode("utf-8"))`
` wmill.write_s3_file(${JSON.stringify(`s3:///${s3Key(output.path)}`)}, json.dumps(rows).encode("utf-8"))`
].join('\n')
case 'datatable': {
const dbName = output.path.split('/')[0] ?? 'main'
+44
View File
@@ -124,5 +124,49 @@ SET s3_secret_access_key='80yMndIMcyXwEujxVNINQbf0tBlIzRaLPyM2m1n4';
wmill.load_s3_file(s3_obj)
class TestParseS3Object(unittest.TestCase):
"""Pure-unit tests for parse_s3_object — no network/env needed."""
def test_bare_string_raises_with_uri_hint(self):
# A bare key is rejected rather than silently uploading under an
# auto-generated name; the error points at the s3:/// spelling.
with self.assertRaisesRegex(ValueError, "s3:///dir/file.json"):
wmill.parse_s3_object("dir/file.json")
def test_triple_slash_uri_is_default_storage(self):
self.assertEqual(
wmill.parse_s3_object("s3:///dir/file.json"),
S3Object(s3="dir/file.json", storage=None),
)
def test_full_uri_splits_storage_and_key(self):
self.assertEqual(
wmill.parse_s3_object("s3://bucket/dir/f"),
S3Object(s3="dir/f", storage="bucket"),
)
def test_malformed_uri_raises(self):
# `s3://x` has no key part — fail loudly instead of silently
# misplacing the object.
with self.assertRaises(ValueError):
wmill.parse_s3_object("s3://broken")
def test_empty_key_uri_raises(self):
# An empty key is never a valid target: it would fall back to an
# auto-generated key, which is requested by omitting the object.
with self.assertRaises(ValueError):
wmill.parse_s3_object("s3:///")
with self.assertRaises(ValueError):
wmill.parse_s3_object("s3://bucket/")
def test_empty_string_raises(self):
# Auto-generated keys are requested by omitting the object (None),
# not by an empty string.
with self.assertRaises(ValueError):
wmill.parse_s3_object("")
def test_s3object_passes_through(self):
self.assertEqual(wmill.parse_s3_object(S3Object(s3="x")), S3Object(s3="x"))
if __name__ == "__main__":
unittest.main()
+19 -4
View File
@@ -2212,12 +2212,27 @@ def parse_resource_syntax(s: str) -> Optional[str]:
return None
def parse_s3_object(s3_object: S3Object | str) -> S3Object:
"""Parse S3 object from string or S3Object format."""
"""Parse S3 object from a `s3://<storage>/<key>` URI string (`s3:///<key>`
for the default storage) or S3Object format. Any other string raises
rather than falling back to an auto-generated key: an auto key is
requested by omitting the object, and a fallback would silently misplace
the upload on any typo.
"""
if isinstance(s3_object, str):
match = re.match(r'^s3://([^/]*)/(.*)$', s3_object)
match = re.match(r'^s3://([^/]*)/(.+)$', s3_object)
if match:
return S3Object(s3=match.group(2) or "", storage=match.group(1) or None)
return S3Object(s3="")
return S3Object(s3=match.group(2), storage=match.group(1) or None)
if s3_object.startswith("s3://"):
raise ValueError(
f"Invalid s3 object URI {s3_object!r}: expected "
"s3://<storage>/<key> with a non-empty key "
"(s3:///<key> for the default storage)"
)
raise ValueError(
f"Invalid s3 object {s3_object!r}: expected an s3://<storage>/<key> "
f"URI (e.g. 's3:///{s3_object}' for key {s3_object!r} in the default "
"storage) or S3Object(s3=<key>)"
)
else:
return s3_object
+16 -8
View File
@@ -1447,13 +1447,6 @@ async requestInteractiveSlackApproval({ slackResourcePath, channelId, message, a
*/
async requestInteractiveTeamsApproval({ teamName, channelName, message, approver, defaultArgsJson, dynamicEnumsJson, }: TeamsApprovalOptions): Promise<void>
/**
* Parse an S3 object from URI string or record format
* @param s3Object - S3 object as URI string (s3://storage/key) or record
* @returns S3 object record with storage and s3 key
*/
parseS3Object(s3Object: S3Object): S3ObjectRecord
setWorkflowCtx(ctx: WorkflowCtx | null): void
async sleep(seconds: number): Promise<void>
@@ -1523,6 +1516,17 @@ async parallel<T, R>(items: T[], fn: (item: T) => PromiseLike<R> | R, options?:
*/
async commitKafkaOffsets(triggerPath: string, topic: string, partition: number, offset: number,): Promise<void>
/**
* Parse an S3 object from URI string or record format
* @param s3Object - S3 object as URI string (\`s3://storage/key\`, \`s3:///key\`
* for the default storage) or record. Any other string throws rather than
* falling back to an auto-generated key: an auto key is requested by
* omitting the object, and a fallback would silently misplace the upload
* on any typo.
* @returns S3 object record with storage and s3 key
*/
parseS3Object(s3Object: S3Object): S3ObjectRecord
/**
* Create a SQL template function for PostgreSQL/datatable queries
* @param name - Database/datatable name (default: "main")
@@ -2108,7 +2112,11 @@ def get_state_path() -> str
# Parse resource syntax from string.
def parse_resource_syntax(s: str) -> Optional[str]
# Parse S3 object from string or S3Object format.
# Parse S3 object from a \`s3://<storage>/<key>\` URI string (\`s3:///<key>\`
# for the default storage) or S3Object format. Any other string raises
# rather than falling back to an auto-generated key: an auto key is
# requested by omitting the object, and a fallback would silently misplace
# the upload on any typo.
def parse_s3_object(s3_object: S3Object | str) -> S3Object
# Parse variable syntax from string.
+16 -8
View File
@@ -1888,13 +1888,6 @@ async requestInteractiveSlackApproval({ slackResourcePath, channelId, message, a
*/
async requestInteractiveTeamsApproval({ teamName, channelName, message, approver, defaultArgsJson, dynamicEnumsJson, }: TeamsApprovalOptions): Promise<void>
/**
* Parse an S3 object from URI string or record format
* @param s3Object - S3 object as URI string (s3://storage/key) or record
* @returns S3 object record with storage and s3 key
*/
parseS3Object(s3Object: S3Object): S3ObjectRecord
setWorkflowCtx(ctx: WorkflowCtx | null): void
async sleep(seconds: number): Promise<void>
@@ -1964,6 +1957,17 @@ async parallel<T, R>(items: T[], fn: (item: T) => PromiseLike<R> | R, options?:
*/
async commitKafkaOffsets(triggerPath: string, topic: string, partition: number, offset: number,): Promise<void>
/**
* Parse an S3 object from URI string or record format
* @param s3Object - S3 object as URI string (`s3://storage/key`, `s3:///key`
* for the default storage) or record. Any other string throws rather than
* falling back to an auto-generated key: an auto key is requested by
* omitting the object, and a fallback would silently misplace the upload
* on any typo.
* @returns S3 object record with storage and s3 key
*/
parseS3Object(s3Object: S3Object): S3ObjectRecord
/**
* Create a SQL template function for PostgreSQL/datatable queries
* @param name - Database/datatable name (default: "main")
@@ -2549,7 +2553,11 @@ def get_state_path() -> str
# Parse resource syntax from string.
def parse_resource_syntax(s: str) -> Optional[str]
# Parse S3 object from string or S3Object format.
# Parse S3 object from a `s3://<storage>/<key>` URI string (`s3:///<key>`
# for the default storage) or S3Object format. Any other string raises
# rather than falling back to an auto-generated key: an auto key is
# requested by omitting the object, and a fallback would silently misplace
# the upload on any typo.
def parse_s3_object(s3_object: S3Object | str) -> S3Object
# Parse variable syntax from string.
+5 -1
View File
@@ -526,7 +526,11 @@ def get_state_path() -> str
# Parse resource syntax from string.
def parse_resource_syntax(s: str) -> Optional[str]
# Parse S3 object from string or S3Object format.
# Parse S3 object from a `s3://<storage>/<key>` URI string (`s3:///<key>`
# for the default storage) or S3Object format. Any other string raises
# rather than falling back to an auto-generated key: an auto key is
# requested by omitting the object, and a fallback would silently misplace
# the upload on any typo.
def parse_s3_object(s3_object: S3Object | str) -> S3Object
# Parse variable syntax from string.
@@ -455,13 +455,6 @@ async requestInteractiveSlackApproval({ slackResourcePath, channelId, message, a
*/
async requestInteractiveTeamsApproval({ teamName, channelName, message, approver, defaultArgsJson, dynamicEnumsJson, }: TeamsApprovalOptions): Promise<void>
/**
* Parse an S3 object from URI string or record format
* @param s3Object - S3 object as URI string (s3://storage/key) or record
* @returns S3 object record with storage and s3 key
*/
parseS3Object(s3Object: S3Object): S3ObjectRecord
setWorkflowCtx(ctx: WorkflowCtx | null): void
async sleep(seconds: number): Promise<void>
@@ -531,6 +524,17 @@ async parallel<T, R>(items: T[], fn: (item: T) => PromiseLike<R> | R, options?:
*/
async commitKafkaOffsets(triggerPath: string, topic: string, partition: number, offset: number,): Promise<void>
/**
* Parse an S3 object from URI string or record format
* @param s3Object - S3 object as URI string (`s3://storage/key`, `s3:///key`
* for the default storage) or record. Any other string throws rather than
* falling back to an auto-generated key: an auto key is requested by
* omitting the object, and a fallback would silently misplace the upload
* on any typo.
* @returns S3 object record with storage and s3 key
*/
parseS3Object(s3Object: S3Object): S3ObjectRecord
/**
* Create a SQL template function for PostgreSQL/datatable queries
* @param name - Database/datatable name (default: "main")
@@ -626,13 +626,6 @@ async requestInteractiveSlackApproval({ slackResourcePath, channelId, message, a
*/
async requestInteractiveTeamsApproval({ teamName, channelName, message, approver, defaultArgsJson, dynamicEnumsJson, }: TeamsApprovalOptions): Promise<void>
/**
* Parse an S3 object from URI string or record format
* @param s3Object - S3 object as URI string (s3://storage/key) or record
* @returns S3 object record with storage and s3 key
*/
parseS3Object(s3Object: S3Object): S3ObjectRecord
setWorkflowCtx(ctx: WorkflowCtx | null): void
async sleep(seconds: number): Promise<void>
@@ -702,6 +695,17 @@ async parallel<T, R>(items: T[], fn: (item: T) => PromiseLike<R> | R, options?:
*/
async commitKafkaOffsets(triggerPath: string, topic: string, partition: number, offset: number,): Promise<void>
/**
* Parse an S3 object from URI string or record format
* @param s3Object - S3 object as URI string (`s3://storage/key`, `s3:///key`
* for the default storage) or record. Any other string throws rather than
* falling back to an auto-generated key: an auto key is requested by
* omitting the object, and a fallback would silently misplace the upload
* on any typo.
* @returns S3 object record with storage and s3 key
*/
parseS3Object(s3Object: S3Object): S3ObjectRecord
/**
* Create a SQL template function for PostgreSQL/datatable queries
* @param name - Database/datatable name (default: "main")
@@ -626,13 +626,6 @@ async requestInteractiveSlackApproval({ slackResourcePath, channelId, message, a
*/
async requestInteractiveTeamsApproval({ teamName, channelName, message, approver, defaultArgsJson, dynamicEnumsJson, }: TeamsApprovalOptions): Promise<void>
/**
* Parse an S3 object from URI string or record format
* @param s3Object - S3 object as URI string (s3://storage/key) or record
* @returns S3 object record with storage and s3 key
*/
parseS3Object(s3Object: S3Object): S3ObjectRecord
setWorkflowCtx(ctx: WorkflowCtx | null): void
async sleep(seconds: number): Promise<void>
@@ -702,6 +695,17 @@ async parallel<T, R>(items: T[], fn: (item: T) => PromiseLike<R> | R, options?:
*/
async commitKafkaOffsets(triggerPath: string, topic: string, partition: number, offset: number,): Promise<void>
/**
* Parse an S3 object from URI string or record format
* @param s3Object - S3 object as URI string (`s3://storage/key`, `s3:///key`
* for the default storage) or record. Any other string throws rather than
* falling back to an auto-generated key: an auto key is requested by
* omitting the object, and a fallback would silently misplace the upload
* on any typo.
* @returns S3 object record with storage and s3 key
*/
parseS3Object(s3Object: S3Object): S3ObjectRecord
/**
* Create a SQL template function for PostgreSQL/datatable queries
* @param name - Database/datatable name (default: "main")
@@ -626,13 +626,6 @@ async requestInteractiveSlackApproval({ slackResourcePath, channelId, message, a
*/
async requestInteractiveTeamsApproval({ teamName, channelName, message, approver, defaultArgsJson, dynamicEnumsJson, }: TeamsApprovalOptions): Promise<void>
/**
* Parse an S3 object from URI string or record format
* @param s3Object - S3 object as URI string (s3://storage/key) or record
* @returns S3 object record with storage and s3 key
*/
parseS3Object(s3Object: S3Object): S3ObjectRecord
setWorkflowCtx(ctx: WorkflowCtx | null): void
async sleep(seconds: number): Promise<void>
@@ -702,6 +695,17 @@ async parallel<T, R>(items: T[], fn: (item: T) => PromiseLike<R> | R, options?:
*/
async commitKafkaOffsets(triggerPath: string, topic: string, partition: number, offset: number,): Promise<void>
/**
* Parse an S3 object from URI string or record format
* @param s3Object - S3 object as URI string (`s3://storage/key`, `s3:///key`
* for the default storage) or record. Any other string throws rather than
* falling back to an auto-generated key: an auto key is requested by
* omitting the object, and a fallback would silently misplace the upload
* on any typo.
* @returns S3 object record with storage and s3 key
*/
parseS3Object(s3Object: S3Object): S3ObjectRecord
/**
* Create a SQL template function for PostgreSQL/datatable queries
* @param name - Database/datatable name (default: "main")
@@ -711,7 +711,11 @@ def get_state_path() -> str
# Parse resource syntax from string.
def parse_resource_syntax(s: str) -> Optional[str]
# Parse S3 object from string or S3Object format.
# Parse S3 object from a `s3://<storage>/<key>` URI string (`s3:///<key>`
# for the default storage) or S3Object format. Any other string raises
# rather than falling back to an auto-generated key: an auto key is
# requested by omitting the object, and a fallback would silently misplace
# the upload on any typo.
def parse_s3_object(s3_object: S3Object | str) -> S3Object
# Parse variable syntax from string.
+2 -11
View File
@@ -16,10 +16,12 @@ import { OpenAPI } from "./core/OpenAPI";
import {
DenoS3LightClientSettings,
S3ObjectRecord,
parseS3Object,
type S3Object,
} from "./s3Types";
export {
parseS3Object,
type S3Object,
type S3ObjectRecord,
type S3ObjectURI,
@@ -1505,17 +1507,6 @@ function parseResourceSyntax(s: string | undefined) {
if (s?.startsWith("res://")) return s.substring(6);
}
/**
* Parse an S3 object from URI string or record format
* @param s3Object - S3 object as URI string (s3://storage/key) or record
* @returns S3 object record with storage and s3 key
*/
export function parseS3Object(s3Object: S3Object): S3ObjectRecord {
if (typeof s3Object === "object") return s3Object;
const match = s3Object.match(/^s3:\/\/([^/]*)\/(.*)$/);
return { storage: match?.[1] || undefined, s3: match?.[2] ?? "" };
}
function parseVariableSyntax(s: string) {
if (s.startsWith("var://")) return s.substring(6);
}
+1
View File
@@ -14,3 +14,4 @@ export type DenoS3LightClientSettings = {
secretKey?: string;
pathStyle?: boolean;
};
export declare function parseS3Object(s3Object: S3Object): S3ObjectRecord;
+25 -1
View File
@@ -4,7 +4,8 @@
export type S3Object = S3ObjectURI | S3ObjectRecord;
/**
* S3 object URI in the format `s3://storage/key`
* S3 object URI in the format `s3://storage/key` (`s3:///key` targets the
* workspace default storage)
*/
export type S3ObjectURI = `s3://${string}/${string}`;
@@ -39,3 +40,26 @@ export type DenoS3LightClientSettings = {
/** Use path-style URLs instead of virtual-hosted style */
pathStyle?: boolean;
};
/**
* Parse an S3 object from URI string or record format
* @param s3Object - S3 object as URI string (`s3://storage/key`, `s3:///key`
* for the default storage) or record. Any other string throws rather than
* falling back to an auto-generated key: an auto key is requested by
* omitting the object, and a fallback would silently misplace the upload
* on any typo.
* @returns S3 object record with storage and s3 key
*/
export function parseS3Object(s3Object: S3Object): S3ObjectRecord {
if (typeof s3Object === "object") return s3Object;
const match = s3Object.match(/^s3:\/\/([^/]*)\/(.+)$/);
if (match) return { storage: match[1] || undefined, s3: match[2] };
if (s3Object.startsWith("s3://")) {
throw new Error(
`Invalid s3 object URI ${JSON.stringify(s3Object)}: expected s3://<storage>/<key> with a non-empty key (s3:///<key> for the default storage)`
);
}
throw new Error(
`Invalid s3 object ${JSON.stringify(s3Object)}: expected an s3://<storage>/<key> URI (e.g. "s3:///${s3Object}" for key "${s3Object}" in the default storage) or { s3: <key> }`
);
}
+52
View File
@@ -0,0 +1,52 @@
import { describe, expect, test } from "bun:test";
import { parseS3Object } from "../s3Types";
describe("parseS3Object", () => {
test("bare string throws with the s3:/// hint", () => {
// A bare key is rejected rather than silently uploading under an
// auto-generated name; the error points at the s3:/// spelling.
expect(() => parseS3Object("dir/file.json" as any)).toThrow(
/s3:\/\/\/dir\/file\.json/
);
});
test("triple-slash URI targets the default storage", () => {
expect(parseS3Object("s3:///dir/file.json")).toEqual({
storage: undefined,
s3: "dir/file.json",
});
});
test("full URI splits storage and key", () => {
expect(parseS3Object("s3://bucket/dir/f")).toEqual({
storage: "bucket",
s3: "dir/f",
});
});
test("malformed s3:// URI throws", () => {
// `s3://x` has no key part — fail loudly instead of silently misplacing
// the object.
expect(() => parseS3Object("s3://broken" as any)).toThrow(
/Invalid s3 object/
);
});
test("empty-key URIs throw", () => {
// An empty key is never a valid target: it would fall back to an
// auto-generated key, which is requested by omitting the object.
expect(() => parseS3Object("s3:///" as any)).toThrow(/Invalid s3 object/);
expect(() => parseS3Object("s3://bucket/")).toThrow(/Invalid s3 object/);
});
test("empty string throws (omit the object for an auto-generated key)", () => {
expect(() => parseS3Object("" as any)).toThrow(/Invalid s3 object/);
});
test("record form passes through", () => {
expect(parseS3Object({ s3: "x", storage: "b" })).toEqual({
s3: "x",
storage: "b",
});
});
});