Repository navigation
feat: Scope SDK identity and scratch paths per request #80
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
8461881
feat: Add invocation identity and scratch helpers
CyMule b7281e1
fix: Fall back to record_id when invocation_id is empty
CyMule 86f9c1b
docs: Describe invocation scope helpers in the 0.1.1 changelog
CyMule 552ab72
fix: Keep bound invocation identity authoritative
CyMule 53d9d6a
fix(invocation): Prevent scratch directory aliases
CyMule 0ebcdb7
refactor(invocation): Import scope helpers from their module
CyMule File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,133 @@ | ||
| import asyncio | ||
| from dataclasses import FrozenInstanceError, dataclass, fields | ||
| from pathlib import Path | ||
|
|
||
| import pytest | ||
| from fastapi.testclient import TestClient | ||
|
|
||
| from unstructured_platform_plugins.etl_uvicorn.api_generator import generate_fast_api | ||
| from unstructured_platform_plugins.invocation_context import InvocationContext | ||
| from unstructured_platform_plugins.invocation_scope import ( | ||
| InvocationIdentity, | ||
| current_invocation_identity, | ||
| invocation_scratch_dir, | ||
| ) | ||
| from unstructured_platform_plugins.invocation_settings import invocation_envelope | ||
|
|
||
|
|
||
| @pytest.mark.parametrize("bound", [False, True]) | ||
| def test_identity_context_is_authoritative(monkeypatch, bound): | ||
| for field in fields(InvocationIdentity): | ||
| monkeypatch.setenv(field.name.upper(), f"env-{field.name}") | ||
| context = InvocationContext(job_id="request", tenant_id="") if bound else None | ||
| with invocation_envelope(None, context): | ||
| identity = current_invocation_identity() | ||
| assert identity.job_id == ("request" if bound else "env-job_id") | ||
| assert identity.tenant_id == ("" if bound else "env-tenant_id") | ||
| for field in fields(InvocationIdentity): | ||
| if field.name not in {"job_id", "tenant_id"}: | ||
| assert getattr(identity, field.name) == (None if bound else f"env-{field.name}") | ||
| with pytest.raises(FrozenInstanceError): | ||
| identity.job_id = "other" | ||
|
|
||
|
|
||
| def test_absent_identity(monkeypatch): | ||
| for field in fields(InvocationIdentity): | ||
| monkeypatch.delenv(field.name.upper(), raising=False) | ||
| assert all( | ||
| getattr(current_invocation_identity(), f.name) is None for f in fields(InvocationIdentity) | ||
| ) | ||
|
|
||
|
|
||
| def test_boot_path_and_creation(tmp_path): | ||
| root = tmp_path / "scratch" | ||
| assert invocation_scratch_dir(root, create=False) == root | ||
| assert not root.exists() | ||
| assert invocation_scratch_dir(str(root)) == root | ||
| assert root.is_dir() | ||
|
|
||
|
|
||
| @pytest.mark.parametrize("segment", ["../a", "..", ".", "a/b", "a\\b", "x" * 300, "☃"]) | ||
| def test_segments_are_safe_and_bounded(tmp_path, segment): | ||
| context = InvocationContext(job_id=segment, dag_node_id=segment, invocation_id=segment) | ||
| with invocation_envelope(None, context): | ||
| path = invocation_scratch_dir(tmp_path) | ||
| assert path.is_relative_to(tmp_path) | ||
| assert len(path.relative_to(tmp_path).parts) == 3 | ||
| assert all(p not in {".", ".."} and len(p) <= 96 for p in path.relative_to(tmp_path).parts) | ||
| assert path.is_dir() | ||
|
|
||
|
|
||
| def test_sanitization_does_not_alias(tmp_path): | ||
| paths = [] | ||
| for job_id in [None, "unknown", "~missing", "a/b", "a_b"]: | ||
| with invocation_envelope(None, InvocationContext(job_id=job_id, invocation_id="invoke")): | ||
| paths.append(invocation_scratch_dir(tmp_path, create=False)) | ||
| encoded_job_id = paths[3].relative_to(tmp_path).parts[0] | ||
| with invocation_envelope( | ||
| None, InvocationContext(job_id=encoded_job_id, invocation_id="invoke") | ||
| ): | ||
| paths.append(invocation_scratch_dir(tmp_path, create=False)) | ||
| assert len(set(paths)) == len(paths) | ||
|
|
||
|
|
||
| def test_single_record_and_batch_correlation(tmp_path): | ||
| with invocation_envelope( | ||
| None, | ||
| InvocationContext( | ||
| job_id="job", dag_node_id="node", invocation_id="invoke", record_id="record" | ||
| ), | ||
| ): | ||
| assert invocation_scratch_dir(tmp_path, create=False) == tmp_path / "job/node/invoke" | ||
| with invocation_envelope(None, InvocationContext(record_id="record")): | ||
| assert invocation_scratch_dir(tmp_path, create=False).name == "record" | ||
| with invocation_envelope(None, InvocationContext(invocation_id="", record_id="record")): | ||
| assert invocation_scratch_dir(tmp_path, create=False).name == "record" | ||
| with invocation_envelope(None, InvocationContext(record_id="record", record_ids=["record"])): | ||
| first = invocation_scratch_dir(tmp_path, create=False) | ||
| second = invocation_scratch_dir(tmp_path, create=False) | ||
| assert first != second | ||
| assert first.name != "record" | ||
|
|
||
|
|
||
| def test_concurrent_invocations_are_isolated(tmp_path): | ||
| async def invoke(job): | ||
| with invocation_envelope( | ||
| None, InvocationContext(job_id=job, dag_node_id="node", invocation_id="invoke") | ||
| ): | ||
| await asyncio.sleep(0) | ||
| return current_invocation_identity().job_id, invocation_scratch_dir(tmp_path) | ||
|
|
||
| async def run(): | ||
| return await asyncio.gather(invoke("one"), invoke("two")) | ||
|
|
||
| results = asyncio.run(run()) | ||
| assert results == [("one", tmp_path / "one/node/invoke"), ("two", tmp_path / "two/node/invoke")] | ||
|
|
||
|
|
||
| @dataclass | ||
| class ScratchOutput: | ||
| path: str | ||
|
|
||
|
|
||
| def test_streaming_retains_invocation_scope(tmp_path): | ||
| async def stream() -> ScratchOutput: | ||
| await asyncio.sleep(0) | ||
| yield ScratchOutput( | ||
| str(invocation_scratch_dir(tmp_path) / current_invocation_identity().job_id) | ||
| ) | ||
|
|
||
| app = generate_fast_api(stream) | ||
| with TestClient(app) as client: | ||
| response = client.post( | ||
| "/invoke", | ||
| json={ | ||
| "invocation_context": { | ||
| "job_id": "job", | ||
| "dag_node_id": "node", | ||
| "invocation_id": "invoke", | ||
| } | ||
| }, | ||
| ) | ||
| assert response.status_code == 200 | ||
| assert str(Path(tmp_path) / "job/node/invoke/job") in response.text |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| """Identity and filesystem scope for the current invocation.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import hashlib | ||
| import os | ||
| import re | ||
| from dataclasses import dataclass, fields | ||
| from pathlib import Path | ||
| from uuid import uuid4 | ||
|
|
||
| from unstructured_platform_plugins.invocation_settings import current_invocation_context | ||
|
|
||
|
|
||
| @dataclass(frozen=True) | ||
| class InvocationIdentity: | ||
| job_id: str | None | ||
| tenant_id: str | None | ||
| org_id: str | None | ||
| workflow_id: str | None | ||
| attribution_id: str | None | ||
| dag_node_id: str | None | ||
| dag_node_type: str | None | ||
| dag_node_subtype: str | None | ||
|
|
||
|
|
||
| def current_invocation_identity() -> InvocationIdentity: | ||
| """Read identity from the bound context, or the environment when no context is bound.""" | ||
| context = current_invocation_context() | ||
| values = {} | ||
| for field in fields(InvocationIdentity): | ||
| values[field.name] = ( | ||
| getattr(context, field.name, None) | ||
| if context is not None | ||
| else os.getenv(field.name.upper()) | ||
| ) | ||
| return InvocationIdentity(**values) | ||
|
|
||
|
|
||
| def _segment(value: str | None) -> str: | ||
| if not value: | ||
| return "~missing" | ||
| safe = re.sub(r"[^a-zA-Z0-9_.-]", "_", value) | ||
| if safe == value and safe not in {".", ".."} and len(safe) <= 96: | ||
| return safe | ||
| digest = hashlib.sha256(value.encode()).hexdigest() | ||
| return f"~{safe[:30].strip('.') or 'unknown'}-{digest}" | ||
|
|
||
|
|
||
| def invocation_scratch_dir(root: str | os.PathLike[str], *, create: bool = True) -> Path: | ||
| """Return an invocation directory, or the unchanged root when no context is bound.""" | ||
| path = Path(root) | ||
| context = current_invocation_context() | ||
| if context is not None: | ||
| identity = current_invocation_identity() | ||
| invocation_id = context.invocation_id | ||
| if not invocation_id and context.record_ids is None and context.invocation_ids is None: | ||
| invocation_id = context.record_id | ||
| path = path / _segment(identity.job_id) / _segment(identity.dag_node_id) | ||
| path = path / _segment(invocation_id or uuid4().hex) | ||
| if create: | ||
| path.mkdir(parents=True, exist_ok=True) | ||
| return path | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.