Skip to content

Commit fa4e938

Browse files
author
Shiven Pandya
committed
Merge remote-tracking branch 'origin/main' into feat/122-stream-endpoint-staging
# Conflicts: # src/api/app.py
2 parents e770bb1 + babf867 commit fa4e938

13 files changed

Lines changed: 293 additions & 24 deletions

File tree

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,3 +177,7 @@ Once the collections have been created, we search through each collection, linki
177177
# Start the FastAPI server
178178
uvicorn api.app:app --app-dir src --reload
179179
```
180+
181+
If you deploy in an environment where default temp directories are not writable
182+
(for example, some ECS task configurations), set `HSDS_TMP_DIR` to a writable
183+
path before starting the API.

src/api/app.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
stage_multipart_uploads,
2121
validate_staged_workspace,
2222
)
23+
from api.tempdir import get_writable_temp_dir
2324
from lib.transform.collections import build_collections, searching_and_assigning
2425
from lib.transform.outputs import save_objects_to_json
2526
from api.model import HealthResponse
@@ -93,8 +94,13 @@ async def transform(
9394
except zipfile.BadZipFile:
9495
raise HTTPException(status_code=422, detail="Invalid zip file")
9596

97+
try:
98+
temp_root = get_writable_temp_dir()
99+
except RuntimeError as exc:
100+
raise HTTPException(status_code=500, detail=str(exc))
101+
96102
# Unzip into a temp directory
97-
with tempfile.TemporaryDirectory() as input_dir:
103+
with tempfile.TemporaryDirectory(dir=temp_root, prefix="hsds-input-") as input_dir:
98104
with zipfile.ZipFile(io.BytesIO(content), "r") as zf:
99105
zf.extractall(input_dir)
100106

@@ -117,7 +123,7 @@ async def transform(
117123
results = searching_and_assigning(results)
118124

119125
# Write each object to JSON files in another temp dir, then zip and return
120-
with tempfile.TemporaryDirectory() as output_dir:
126+
with tempfile.TemporaryDirectory(dir=temp_root, prefix="hsds-output-") as output_dir:
121127
save_objects_to_json(results, output_dir)
122128
buf = io.BytesIO()
123129
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as out_zip:

src/api/tempdir.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import os
2+
import tempfile
3+
from pathlib import Path
4+
5+
6+
def _default_temp_candidates() -> list[Path]:
7+
"""Return ordered fallback temp-directory candidates."""
8+
return [Path(tempfile.gettempdir()), Path("/tmp"), Path("/var/tmp"), Path("/usr/tmp")]
9+
10+
11+
def _is_writable_dir(path: Path) -> bool:
12+
"""Create directory (if needed) and verify write access."""
13+
try:
14+
path.mkdir(parents=True, exist_ok=True)
15+
except OSError:
16+
return False
17+
18+
try:
19+
with tempfile.NamedTemporaryFile(dir=path, prefix=".hsds-write-test-", delete=True):
20+
pass
21+
except OSError:
22+
return False
23+
24+
return True
25+
26+
27+
def get_writable_temp_dir(env_var: str = "HSDS_TMP_DIR") -> str:
28+
"""Resolve a writable temp directory for API file operations."""
29+
configured = os.getenv(env_var)
30+
if configured:
31+
candidate_paths = [Path(configured)]
32+
else:
33+
candidate_paths = _default_temp_candidates()
34+
35+
for candidate in candidate_paths:
36+
if _is_writable_dir(candidate):
37+
return str(candidate)
38+
39+
raise RuntimeError(
40+
f"No writable temporary directory available. Set {env_var} to a writable path. "
41+
f"Checked: {[str(path) for path in candidate_paths]}"
42+
)

src/cli/main.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,40 @@
1+
from pathlib import Path
2+
13
from ..lib.transform.outputs import save_objects_to_json
24
from ..lib.transform.collections import build_collections, searching_and_assigning
35
from ..lib.transform.logger import transformer_log
6+
from ..lib.custom_transform.transforms_loader import load_transforms_registry_if_available
47
import click
58
import sys
69

710
@click.command()
811
@click.argument('data_dictionary', type=click.Path(exists=True, file_okay=False, dir_okay=True, readable=True))
912
@click.option('--output-dir', '-o', default='output', help='Output directory for JSON files')
1013
@click.option('--generate-ids', default=None, help='Generate new IDs using the provided organization name/id')
11-
12-
def main(data_dictionary, output_dir, generate_ids):
14+
@click.option(
15+
'--transforms',
16+
type=click.Path(exists=False, dir_okay=False, file_okay=True, path_type=Path),
17+
default=None,
18+
help='Path to a Python module defining custom transforms (optional; omitted or missing file runs without them)',
19+
)
20+
21+
def main(data_dictionary, output_dir, generate_ids, transforms):
1322
try:
1423
# Clear any previous log entries from prior runs
1524
transformer_log.clear()
1625

26+
transforms_registry = load_transforms_registry_if_available(transforms)
27+
if transforms is None:
28+
transformer_log.log("Custom transforms: not used (no --transforms path).")
29+
elif transforms_registry is None:
30+
transformer_log.log(
31+
f"Custom transforms: not used (path not found or not a file: {transforms})."
32+
)
33+
else:
34+
transformer_log.log(
35+
f"Custom transforms: loaded from {transforms.resolve()}."
36+
)
37+
1738
results = build_collections(data_dictionary) # Builds collections
1839
results = searching_and_assigning(results, requestor_identifier=generate_ids) # Links and cleans up, passes transformer_id
1940

src/lib/custom_transform/__init__.py

Whitespace-only changes.
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
"""Custom exception for failures in user-provided transform/hook code."""
2+
3+
from typing import Any
4+
5+
6+
class CustomTransformError(Exception):
7+
"""
8+
Raised when an error occurs inside the user's custom transform or hook code.
9+
10+
Use this in a try/except around user code so callers can distinguish
11+
user-script failures from transformer codebase failures. When re-raising,
12+
pass the original exception as the cause to preserve the traceback::
13+
14+
try:
15+
result = user_transform(value)
16+
except Exception as e:
17+
raise CustomTransformError(
18+
"Field transformation failed",
19+
function_name="clean_phone",
20+
row_index=row_index,
21+
cause=e,
22+
) from e
23+
24+
Context parameters (function_name, row_index, stage, etc.) are stored and
25+
included in the exception message to help users debug their custom code.
26+
"""
27+
28+
def __init__(
29+
self,
30+
message: str,
31+
*,
32+
function_name: str | None = None,
33+
row_index: int | None = None,
34+
stage: str | None = None,
35+
cause: BaseException | None = None,
36+
**context: Any,
37+
) -> None:
38+
super().__init__(message)
39+
self.message = message
40+
self.function_name = function_name
41+
self.row_index = row_index
42+
self.stage = stage
43+
self.cause = cause
44+
self.context = context
45+
46+
def __str__(self) -> str:
47+
parts = [
48+
"Error in user-provided custom transform code.",
49+
self.message,
50+
]
51+
if self.function_name is not None:
52+
parts.append(f"Function: {self.function_name!r}.")
53+
if self.row_index is not None:
54+
parts.append(f"Row index: {self.row_index}.")
55+
if self.stage is not None:
56+
parts.append(f"Stage: {self.stage!r}.")
57+
for key, value in self.context.items():
58+
parts.append(f"{key}: {value}.")
59+
if self.cause is not None:
60+
parts.append(f"Caused by: {type(self.cause).__name__}: {self.cause}.")
61+
return " ".join(parts)
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
"""Handles loading user-provided custom modules. Its purpose is to dynamically
2+
import a single dedicated Python file defined by the user that contains their
3+
customized cleanup functions."""
4+
5+
import importlib.util
6+
from pathlib import Path
7+
from typing import Callable
8+
9+
__all__ = ["TransformsRegistry", "load_transforms_registry_if_available"]
10+
11+
12+
def load_transforms_registry_if_available(
13+
module_path: str | Path | None,
14+
) -> "TransformsRegistry | None":
15+
"""
16+
Load a TransformsRegistry from a file path, or return None when custom
17+
transforms should not be used (no path, empty path, or missing file).
18+
"""
19+
if module_path is None:
20+
return None
21+
if isinstance(module_path, str) and not module_path.strip():
22+
return None
23+
path = Path(module_path).expanduser()
24+
try:
25+
path = path.resolve()
26+
except OSError:
27+
return None
28+
if not path.is_file():
29+
return None
30+
return TransformsRegistry(path)
31+
32+
33+
class TransformsRegistry:
34+
"""
35+
Loads, stores, and accesses relevant information from a user-defined
36+
custom-transforms module.
37+
"""
38+
39+
def __init__(self, module_path: Path):
40+
"""
41+
Loads custom transforms module, loads transforms and hooks
42+
43+
Module must define:
44+
transforms: dict
45+
hooks : dict
46+
47+
TODO: Implement error handling
48+
"""
49+
50+
module_name = module_path.stem
51+
spec = importlib.util.spec_from_file_location(module_name, module_path)
52+
module = importlib.util.module_from_spec(spec)
53+
spec.loader.exec_module(module)
54+
55+
self._transforms = module.transforms
56+
self._hooks = module.hooks
57+
58+
59+
def get_transform(self, name: str) -> Callable:
60+
"""
61+
Looks up specific field-level transformations by their string name.
62+
TODO: Implement error handling
63+
"""
64+
return self._transforms[name]
65+
66+
def get_hook(self, stage: str) -> Callable:
67+
"""
68+
Retrieves broad row-level or collection-level hooks.
69+
TODO: Implement error handling
70+
"""
71+
return self._hooks[stage]

src/lib/maintenance/generate_mapping/writer.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"input_files_field",
1212
"split",
1313
"strip",
14+
"transform",
1415
"description",
1516
"required",
1617
]
@@ -26,4 +27,4 @@ def write_mapping_template_csv(rows: Iterable[FieldSpec], out_file: str) -> None
2627

2728
for row in rows:
2829
required = "true" if row.required else "false"
29-
writer.writerow([row.path, "", "", "", row.description, required])
30+
writer.writerow([row.path, "", "", "", "", row.description, required])

src/lib/transform/custom_transform/transforms_loader.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,28 @@
66
from pathlib import Path
77
from typing import Callable
88

9-
__all__ = ["TransformsRegistry"]
9+
__all__ = ["TransformsRegistry", "load_transforms_registry_if_available"]
10+
11+
12+
def load_transforms_registry_if_available(
13+
module_path: str | Path | None,
14+
) -> "TransformsRegistry | None":
15+
"""
16+
Load a TransformsRegistry from a file path, or return None when custom
17+
transforms should not be used (no path, empty path, or missing file).
18+
"""
19+
if module_path is None:
20+
return None
21+
if isinstance(module_path, str) and not module_path.strip():
22+
return None
23+
path = Path(module_path).expanduser()
24+
try:
25+
path = path.resolve()
26+
except OSError:
27+
return None
28+
if not path.is_file():
29+
return None
30+
return TransformsRegistry(path)
1031

1132

1233
class TransformsRegistry:
Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
1-
path,input_files_field,split,strip,description,required
2-
,,,,,
3-
id,,,,Identifier,true
4-
name,,,,Name,false
5-
details,,,,Details,true
6-
details.summary,,,,Summary,true
7-
details.notes,,,,Notes,false
8-
tags[],,,,Tags,true
9-
contacts[],,,,Contacts,false
10-
contacts[].email,,,,Email,false
11-
contacts[].phone,,,,Phone,false
12-
contacts[].addresses[],,,,Addresses,false
13-
contacts[].addresses[].line1,,,,Line 1,false
1+
path,input_files_field,split,strip,transform,description,required
2+
,,,,,,
3+
id,,,,,Identifier,true
4+
name,,,,,Name,false
5+
details,,,,,Details,true
6+
details.summary,,,,,Summary,true
7+
details.notes,,,,,Notes,false
8+
tags[],,,,,Tags,true
9+
contacts[],,,,,Contacts,false
10+
contacts[].email,,,,,Email,false
11+
contacts[].phone,,,,,Phone,false
12+
contacts[].addresses[],,,,,Addresses,false
13+
contacts[].addresses[].line1,,,,,Line 1,false

0 commit comments

Comments
 (0)