mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 16:02:10 +00:00
5ad2de91a2
* 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>
173 lines
6.4 KiB
Python
173 lines
6.4 KiB
Python
import unittest
|
|
import wmill
|
|
from wmill import S3Object
|
|
import os
|
|
|
|
|
|
class TestStringMethods(unittest.TestCase):
|
|
_token = "<WM_TOKEN>"
|
|
_workspace = "storage"
|
|
_host = "http://localhost:8000"
|
|
_resource_path = "u/admin/docker_minio"
|
|
|
|
def setUp(self):
|
|
os.environ["WM_WORKSPACE"] = self._workspace
|
|
os.environ["WM_TOKEN"] = self._token
|
|
os.environ["BASE_INTERNAL_URL"] = self._host
|
|
|
|
@unittest.skip("skipping")
|
|
def test_duckdb_connection_settings(self):
|
|
settings = wmill.duckdb_connection_settings(self._resource_path)
|
|
self.assertIsNotNone(settings)
|
|
|
|
expected_settings_str = """SET home_directory='./';
|
|
INSTALL 'httpfs';
|
|
SET s3_url_style='path';
|
|
SET s3_region='fr-paris';
|
|
SET s3_endpoint='localhost:9000';
|
|
SET s3_use_ssl=0;
|
|
SET s3_access_key_id='IeuKPSYLKTO2h9CWfCVR';
|
|
SET s3_secret_access_key='80yMndIMcyXwEujxVNINQbf0tBlIzRaLPyM2m1n4';
|
|
"""
|
|
|
|
self.assertEqual(settings["connection_settings_str"], expected_settings_str)
|
|
self.assertEqual(settings.connection_settings_str, expected_settings_str)
|
|
|
|
settings = wmill.polars_connection_settings(self._resource_path)
|
|
print(settings)
|
|
|
|
@unittest.skip("skipping")
|
|
def test_polars_connection_settings(self):
|
|
settings = wmill.polars_connection_settings(self._resource_path)
|
|
s3fs_args_expected = {
|
|
"endpoint_url": "http://localhost:9000",
|
|
"key": "IeuKPSYLKTO2h9CWfCVR",
|
|
"secret": "80yMndIMcyXwEujxVNINQbf0tBlIzRaLPyM2m1n4",
|
|
"use_ssl": False,
|
|
"cache_regions": False,
|
|
"client_kwargs": {"region_name": "fr-paris"},
|
|
}
|
|
polars_cloud_options_expected = {
|
|
"aws_endpoint_url": "http://localhost:9000",
|
|
"aws_access_key_id": "IeuKPSYLKTO2h9CWfCVR",
|
|
"aws_secret_access_key": "80yMndIMcyXwEujxVNINQbf0tBlIzRaLPyM2m1n4",
|
|
"aws_region": "fr-paris",
|
|
"aws_allow_http": True,
|
|
}
|
|
self.assertEqual(settings["s3fs_args"], s3fs_args_expected)
|
|
self.assertEqual(settings.s3fs_args, s3fs_args_expected)
|
|
self.assertEqual(
|
|
settings["polars_cloud_options"], polars_cloud_options_expected
|
|
)
|
|
self.assertEqual(settings.polars_cloud_options, polars_cloud_options_expected)
|
|
|
|
@unittest.skip("skipping")
|
|
def test_boto3_connection_settings(self):
|
|
settings = wmill.boto3_connection_settings(self._resource_path)
|
|
expected_settings = {
|
|
"endpoint_url": "http://localhost:9000",
|
|
"region_name": "fr-paris",
|
|
"use_ssl": False,
|
|
"aws_access_key_id": "IeuKPSYLKTO2h9CWfCVR",
|
|
"aws_secret_access_key": "80yMndIMcyXwEujxVNINQbf0tBlIzRaLPyM2m1n4",
|
|
}
|
|
self.assertEqual(settings, expected_settings)
|
|
self.assertEqual(settings["endpoint_url"], "http://localhost:9000")
|
|
self.assertEqual(settings.endpoint_url, "http://localhost:9000")
|
|
|
|
@unittest.skip("skipping")
|
|
def test_download_s3_file(self):
|
|
with wmill.load_s3_file_reader(S3Object(s3="region.csv")) as file_content, open(
|
|
"region.csv", "wb"
|
|
) as output_file:
|
|
output_file.write(file_content.read())
|
|
|
|
@unittest.skip("skipping")
|
|
def test_download_s3_file_content(self):
|
|
file_content = wmill.load_s3_file(S3Object(s3="region.csv"))
|
|
print(file_content)
|
|
|
|
@unittest.skip("skipping")
|
|
def test_upload_s3_file(self):
|
|
with open("region.csv", "rb") as file_content:
|
|
file_key = wmill.write_s3_file(S3Object(s3="region.csv"), file_content)
|
|
print(file_key)
|
|
|
|
@unittest.skip("skipping")
|
|
def test_upload_s3_raw_bytes(self):
|
|
file_key = wmill.write_s3_file(
|
|
S3Object(s3="hello-world.txt"), b"Hello Windmill!"
|
|
)
|
|
print(file_key)
|
|
|
|
@unittest.skip("skipping")
|
|
def test_download_upload_s3_file(self):
|
|
with wmill.load_s3_file_reader(S3Object(s3="customer.csv")) as file_content:
|
|
file_key = wmill.write_s3_file(
|
|
S3Object(s3="customer_test.csv"), file_content
|
|
)
|
|
print(file_key)
|
|
|
|
@unittest.skip("skipping")
|
|
def test_delete_s3_object(self):
|
|
# Upload a temporary file
|
|
s3_obj = wmill.write_s3_file(
|
|
S3Object(s3="_wmill_test_delete_s3_object.txt"), b"delete_s3_object test content"
|
|
)
|
|
# Verify it exists
|
|
content = wmill.load_s3_file(s3_obj)
|
|
self.assertEqual(content, b"delete_s3_object test content")
|
|
# Delete it
|
|
wmill.delete_s3_object(s3_obj)
|
|
# Verify it's gone
|
|
with self.assertRaises(Exception):
|
|
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()
|