nodekit 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nodekit/.DS_Store +0 -0
- nodekit/__init__.py +53 -0
- nodekit/_internal/__init__.py +0 -0
- nodekit/_internal/ops/__init__.py +0 -0
- nodekit/_internal/ops/build_site/__init__.py +117 -0
- nodekit/_internal/ops/build_site/harness.j2 +158 -0
- nodekit/_internal/ops/concat.py +51 -0
- nodekit/_internal/ops/open_asset_save_asset.py +125 -0
- nodekit/_internal/ops/play/__init__.py +4 -0
- nodekit/_internal/ops/play/local_runner/__init__.py +0 -0
- nodekit/_internal/ops/play/local_runner/main.py +234 -0
- nodekit/_internal/ops/play/local_runner/site-template.j2 +38 -0
- nodekit/_internal/ops/save_graph_load_graph.py +131 -0
- nodekit/_internal/ops/topological_sorting.py +122 -0
- nodekit/_internal/types/__init__.py +0 -0
- nodekit/_internal/types/actions/__init__.py +0 -0
- nodekit/_internal/types/actions/actions.py +89 -0
- nodekit/_internal/types/assets/__init__.py +151 -0
- nodekit/_internal/types/cards/__init__.py +85 -0
- nodekit/_internal/types/events/__init__.py +0 -0
- nodekit/_internal/types/events/events.py +145 -0
- nodekit/_internal/types/expressions/__init__.py +0 -0
- nodekit/_internal/types/expressions/expressions.py +242 -0
- nodekit/_internal/types/graph.py +42 -0
- nodekit/_internal/types/node.py +21 -0
- nodekit/_internal/types/regions/__init__.py +13 -0
- nodekit/_internal/types/sensors/__init__.py +0 -0
- nodekit/_internal/types/sensors/sensors.py +156 -0
- nodekit/_internal/types/trace.py +17 -0
- nodekit/_internal/types/transition.py +68 -0
- nodekit/_internal/types/value.py +145 -0
- nodekit/_internal/utils/__init__.py +0 -0
- nodekit/_internal/utils/get_browser_bundle.py +35 -0
- nodekit/_internal/utils/get_extension_from_media_type.py +15 -0
- nodekit/_internal/utils/hashing.py +46 -0
- nodekit/_internal/utils/iter_assets.py +61 -0
- nodekit/_internal/version.py +1 -0
- nodekit/_static/nodekit.css +10 -0
- nodekit/_static/nodekit.js +59 -0
- nodekit/actions/__init__.py +25 -0
- nodekit/assets/__init__.py +7 -0
- nodekit/cards/__init__.py +15 -0
- nodekit/events/__init__.py +30 -0
- nodekit/experimental/.DS_Store +0 -0
- nodekit/experimental/__init__.py +0 -0
- nodekit/experimental/recruitment_services/__init__.py +0 -0
- nodekit/experimental/recruitment_services/base.py +77 -0
- nodekit/experimental/recruitment_services/mechanical_turk/__init__.py +0 -0
- nodekit/experimental/recruitment_services/mechanical_turk/client.py +359 -0
- nodekit/experimental/recruitment_services/mechanical_turk/models.py +116 -0
- nodekit/experimental/s3.py +219 -0
- nodekit/experimental/turk_helper.py +223 -0
- nodekit/experimental/visualization/.DS_Store +0 -0
- nodekit/experimental/visualization/__init__.py +0 -0
- nodekit/experimental/visualization/pointer.py +443 -0
- nodekit/expressions/__init__.py +55 -0
- nodekit/sensors/__init__.py +25 -0
- nodekit/transitions/__init__.py +15 -0
- nodekit/values/__init__.py +63 -0
- nodekit-0.2.0.dist-info/METADATA +221 -0
- nodekit-0.2.0.dist-info/RECORD +62 -0
- nodekit-0.2.0.dist-info/WHEEL +4 -0
nodekit/.DS_Store
ADDED
|
Binary file
|
nodekit/__init__.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
__all__ = [
|
|
2
|
+
"VERSION",
|
|
3
|
+
# Top-level types:
|
|
4
|
+
"Node",
|
|
5
|
+
"Graph",
|
|
6
|
+
"Trace",
|
|
7
|
+
# One-off top-level types:
|
|
8
|
+
"Region",
|
|
9
|
+
# Namespaced types:
|
|
10
|
+
"assets",
|
|
11
|
+
"cards",
|
|
12
|
+
"sensors",
|
|
13
|
+
"actions",
|
|
14
|
+
"events",
|
|
15
|
+
"transitions",
|
|
16
|
+
"expressions",
|
|
17
|
+
"values",
|
|
18
|
+
# Ops:
|
|
19
|
+
"play",
|
|
20
|
+
"concat",
|
|
21
|
+
"save_graph",
|
|
22
|
+
"load_graph",
|
|
23
|
+
"open_asset",
|
|
24
|
+
"build_site",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
# Version
|
|
28
|
+
from nodekit._internal.version import VERSION
|
|
29
|
+
|
|
30
|
+
# Incoming models:
|
|
31
|
+
from nodekit._internal.types.node import Node
|
|
32
|
+
from nodekit._internal.types.trace import Trace
|
|
33
|
+
from nodekit._internal.types.graph import Graph
|
|
34
|
+
|
|
35
|
+
# Random
|
|
36
|
+
from nodekit._internal.types.regions import Region
|
|
37
|
+
|
|
38
|
+
# Namespaced types:
|
|
39
|
+
import nodekit.cards as cards
|
|
40
|
+
import nodekit.assets as assets
|
|
41
|
+
import nodekit.sensors as sensors
|
|
42
|
+
import nodekit.actions as actions
|
|
43
|
+
import nodekit.events as events
|
|
44
|
+
import nodekit.transitions as transitions
|
|
45
|
+
import nodekit.expressions as expressions
|
|
46
|
+
import nodekit.values as values
|
|
47
|
+
|
|
48
|
+
# Ops:
|
|
49
|
+
from nodekit._internal.ops.play import play
|
|
50
|
+
from nodekit._internal.ops.concat import concat
|
|
51
|
+
from nodekit._internal.ops.save_graph_load_graph import save_graph, load_graph
|
|
52
|
+
from nodekit._internal.ops.open_asset_save_asset import open_asset
|
|
53
|
+
from nodekit._internal.ops.build_site import build_site
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
import jinja2
|
|
5
|
+
import pydantic
|
|
6
|
+
|
|
7
|
+
from nodekit._internal.ops.open_asset_save_asset import save_asset
|
|
8
|
+
from nodekit._internal.types.assets import RelativePath
|
|
9
|
+
from nodekit._internal.types.graph import Graph
|
|
10
|
+
from nodekit._internal.utils.get_browser_bundle import get_browser_bundle
|
|
11
|
+
from nodekit._internal.utils.get_extension_from_media_type import (
|
|
12
|
+
get_extension_from_media_type,
|
|
13
|
+
)
|
|
14
|
+
from nodekit._internal.utils.hashing import hash_string
|
|
15
|
+
from nodekit._internal.utils.iter_assets import iter_assets
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# %%
|
|
19
|
+
class BuildSiteResult(pydantic.BaseModel):
|
|
20
|
+
site_root: Path = pydantic.Field(
|
|
21
|
+
description="The absolute path to the folder containing the site."
|
|
22
|
+
)
|
|
23
|
+
entrypoint: Path = pydantic.Field(
|
|
24
|
+
description="The path of the index html (relative to the root)."
|
|
25
|
+
)
|
|
26
|
+
dependencies: list[Path] = pydantic.Field(
|
|
27
|
+
description="List of paths to all files needed by the index html, relative to the root."
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def build_site(
|
|
32
|
+
graph: Graph,
|
|
33
|
+
savedir: os.PathLike | str,
|
|
34
|
+
) -> BuildSiteResult:
|
|
35
|
+
"""
|
|
36
|
+
Builds a static website for the given Graph, saving all assets and the HTML file to the given directory.
|
|
37
|
+
Returns a path to the entrypoint HTML file of the site ({graph-id}.html).
|
|
38
|
+
|
|
39
|
+
assets/
|
|
40
|
+
{mime-type-1}/{mime-type-2}/{sha256}.{ext}
|
|
41
|
+
runtime/
|
|
42
|
+
nodekit.{js-digest}.js
|
|
43
|
+
nodekit.{css-digest}.css
|
|
44
|
+
graphs/
|
|
45
|
+
{graph_digest}/
|
|
46
|
+
index.html
|
|
47
|
+
graph.json
|
|
48
|
+
"""
|
|
49
|
+
savedir = Path(savedir)
|
|
50
|
+
if not savedir.exists():
|
|
51
|
+
savedir.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
|
|
53
|
+
if not savedir.is_dir():
|
|
54
|
+
raise ValueError(f"Savedir must be a directory: {savedir}")
|
|
55
|
+
|
|
56
|
+
dependencies = []
|
|
57
|
+
|
|
58
|
+
# Ensure the browser runtime is saved to the appropriate location:
|
|
59
|
+
browser_bundle = get_browser_bundle()
|
|
60
|
+
css_relative_path = Path("runtime") / f"nodekit.{browser_bundle.css_sha256}.css"
|
|
61
|
+
js_relative_path = Path("runtime") / f"nodekit.{browser_bundle.js_sha256}.js"
|
|
62
|
+
css_abs_path = savedir / css_relative_path
|
|
63
|
+
js_abs_path = savedir / js_relative_path
|
|
64
|
+
dependencies.append(css_relative_path)
|
|
65
|
+
dependencies.append(js_relative_path)
|
|
66
|
+
if not css_abs_path.exists():
|
|
67
|
+
css_abs_path.parent.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
css_abs_path.write_text(browser_bundle.css)
|
|
69
|
+
if not js_abs_path.exists():
|
|
70
|
+
js_abs_path.parent.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
js_abs_path.write_text(browser_bundle.js)
|
|
72
|
+
|
|
73
|
+
# Ensure all assets saved to the appropriate location:
|
|
74
|
+
graph = graph.model_copy(deep=True)
|
|
75
|
+
for asset in iter_assets(graph=graph):
|
|
76
|
+
asset_relative_path = (
|
|
77
|
+
Path("assets")
|
|
78
|
+
/ asset.media_type
|
|
79
|
+
/ f"{asset.sha256}.{get_extension_from_media_type(asset.media_type)}"
|
|
80
|
+
)
|
|
81
|
+
asset_abs_path = savedir / asset_relative_path
|
|
82
|
+
dependencies.append(asset_relative_path)
|
|
83
|
+
if not asset_abs_path.exists():
|
|
84
|
+
# Copy the asset to the savepath:
|
|
85
|
+
asset_abs_path.parent.mkdir(parents=True, exist_ok=True)
|
|
86
|
+
save_asset(
|
|
87
|
+
asset=asset,
|
|
88
|
+
path=asset_abs_path,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
# Mutate the asset locator in the graph to be a RelativePath - relative to the graph!:
|
|
92
|
+
asset.locator = RelativePath(relative_path=Path("../..") / asset_relative_path)
|
|
93
|
+
|
|
94
|
+
# Render the HTML site using the Jinja2 template:
|
|
95
|
+
jinja2_location = Path(__file__).parent / "harness.j2"
|
|
96
|
+
jinja2_loader = jinja2.FileSystemLoader(searchpath=jinja2_location.parent)
|
|
97
|
+
jinja2_env = jinja2.Environment(loader=jinja2_loader)
|
|
98
|
+
template = jinja2_env.get_template(jinja2_location.name)
|
|
99
|
+
rendered_html = template.render(
|
|
100
|
+
graph=graph.model_dump(mode="json"),
|
|
101
|
+
css_path=Path("../..") / css_relative_path,
|
|
102
|
+
js_path=Path("../..") / js_relative_path,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
# Save the graph site:
|
|
106
|
+
graph_serialized = graph.model_dump_json()
|
|
107
|
+
graph_digest = hash_string(s=graph_serialized)
|
|
108
|
+
graph_dir = savedir / "graphs" / graph_digest
|
|
109
|
+
graph_dir.mkdir(parents=True, exist_ok=True)
|
|
110
|
+
graph_html_path = graph_dir / "index.html"
|
|
111
|
+
graph_html_path.write_text(rendered_html)
|
|
112
|
+
|
|
113
|
+
return BuildSiteResult(
|
|
114
|
+
site_root=savedir.resolve(),
|
|
115
|
+
entrypoint=graph_html_path.relative_to(savedir),
|
|
116
|
+
dependencies=dependencies,
|
|
117
|
+
)
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<title>NodeKit</title>
|
|
6
|
+
<link rel="shortcut icon" href="">
|
|
7
|
+
|
|
8
|
+
<!-- NodeKit -->
|
|
9
|
+
<script src="{{ js_path }}"></script>
|
|
10
|
+
<link href="{{ css_path }}" rel="stylesheet">
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
<script>
|
|
14
|
+
function tryInferTurkContext() {
|
|
15
|
+
if (typeof window === "undefined" || !window.location) {
|
|
16
|
+
return null
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const params = new URLSearchParams(window.location.search);
|
|
20
|
+
|
|
21
|
+
// MTurk-style query params
|
|
22
|
+
const assignmentId = params.get("assignmentId");
|
|
23
|
+
const hitId = params.get("hitId");
|
|
24
|
+
const workerId = params.get("workerId");
|
|
25
|
+
const turkSubmitTo = params.get("turkSubmitTo");
|
|
26
|
+
|
|
27
|
+
const looksLikeMTurk =
|
|
28
|
+
assignmentId !== null ||
|
|
29
|
+
hitId !== null ||
|
|
30
|
+
workerId !== null ||
|
|
31
|
+
turkSubmitTo !== null;
|
|
32
|
+
|
|
33
|
+
if (!looksLikeMTurk) {
|
|
34
|
+
return null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Preview mode per MTurk docs: assignmentId is present and equals this sentinel.
|
|
38
|
+
const PREVIEW_SENTINEL = "ASSIGNMENT_ID_NOT_AVAILABLE";
|
|
39
|
+
const preview_mode = assignmentId === PREVIEW_SENTINEL;
|
|
40
|
+
|
|
41
|
+
if (preview_mode) {
|
|
42
|
+
return {
|
|
43
|
+
previewMode: true,
|
|
44
|
+
context: null,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Not previewing → require all fields
|
|
49
|
+
if (!turkSubmitTo || !hitId || !assignmentId || !workerId) {
|
|
50
|
+
const e = new Error("Missing required parameters in the query for MTurk platform.");
|
|
51
|
+
e.name = "BadPlatformContextError";
|
|
52
|
+
throw e;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
previewMode: false,
|
|
57
|
+
context: {
|
|
58
|
+
assignmentId: assignmentId,
|
|
59
|
+
turkSubmitTo: turkSubmitTo,
|
|
60
|
+
workerId: workerId,
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
</script>
|
|
65
|
+
<script>
|
|
66
|
+
function addPreviewSplash(){
|
|
67
|
+
if (typeof window === "undefined" || !window.location) {
|
|
68
|
+
return null
|
|
69
|
+
}
|
|
70
|
+
const div = document.createElement('div');
|
|
71
|
+
div.textContent = 'Preview Mode. Accept the HIT to continue.';
|
|
72
|
+
Object.assign(div.style, {
|
|
73
|
+
position: 'fixed',
|
|
74
|
+
top: '50%',
|
|
75
|
+
left: '50%',
|
|
76
|
+
transform: 'translate(-50%, -50%)',
|
|
77
|
+
padding: '1.5rem 2rem',
|
|
78
|
+
background: 'white',
|
|
79
|
+
border: '2px solid #ccc',
|
|
80
|
+
borderRadius: '8px',
|
|
81
|
+
fontFamily: 'sans-serif',
|
|
82
|
+
fontSize: '1.2rem',
|
|
83
|
+
textAlign: 'center',
|
|
84
|
+
boxShadow: '0 0 2px rgba(0,0,0,0.2)',
|
|
85
|
+
});
|
|
86
|
+
document.body.appendChild(div);
|
|
87
|
+
}
|
|
88
|
+
</script>
|
|
89
|
+
|
|
90
|
+
<script>
|
|
91
|
+
function submitToTurk(
|
|
92
|
+
trace,
|
|
93
|
+
assignmentId,
|
|
94
|
+
turkSubmitTo,
|
|
95
|
+
){
|
|
96
|
+
// Submission procedure for Mechanical Turk. See: https://docs.aws.amazon.com/AWSMechTurk/latest/AWSMechanicalTurkRequester/mturk-hits-defining-questions-html-javascript.html
|
|
97
|
+
// Note the following undocumented gotcha: the form must have at least one input element other than assignmentId, otherwise
|
|
98
|
+
// Mechanical Turk will not accept the submission. Here, we have the input element `sessionId`, which satisfies this requirement.
|
|
99
|
+
|
|
100
|
+
// Get context variables:
|
|
101
|
+
|
|
102
|
+
// create the form element and point it to the correct endpoint
|
|
103
|
+
const form = document.createElement('form')
|
|
104
|
+
form.action = (new URL('mturk/externalSubmit', turkSubmitTo)).href
|
|
105
|
+
form.method = 'post'
|
|
106
|
+
|
|
107
|
+
// attach the assignmentId
|
|
108
|
+
const inputAssignmentId = document.createElement('input')
|
|
109
|
+
inputAssignmentId.name = 'assignmentId'
|
|
110
|
+
inputAssignmentId.value = assignmentId
|
|
111
|
+
inputAssignmentId.hidden = true
|
|
112
|
+
form.appendChild(inputAssignmentId)
|
|
113
|
+
|
|
114
|
+
// attach a runId to allow for Experimenters to map HITs to Sessions
|
|
115
|
+
const inputRunId = document.createElement('input')
|
|
116
|
+
inputRunId.name = 'trace';
|
|
117
|
+
inputRunId.value = JSON.stringify(trace);
|
|
118
|
+
inputRunId.hidden = true;
|
|
119
|
+
form.appendChild(inputRunId)
|
|
120
|
+
|
|
121
|
+
// attach the form to the HTML document and trigger submission
|
|
122
|
+
document.body.appendChild(form)
|
|
123
|
+
|
|
124
|
+
// Submit the form
|
|
125
|
+
form.submit()
|
|
126
|
+
}
|
|
127
|
+
</script>
|
|
128
|
+
|
|
129
|
+
<script>
|
|
130
|
+
const graph = {{ graph | tojson(indent=4) | indent(8, first=False) }};
|
|
131
|
+
|
|
132
|
+
window.onload = async () => {
|
|
133
|
+
// Infer the context:
|
|
134
|
+
const turkContext = tryInferTurkContext()
|
|
135
|
+
|
|
136
|
+
// Guard execution if in preview mode
|
|
137
|
+
if (turkContext && turkContext.previewMode === true){
|
|
138
|
+
// Add a simple, centered div in the middle of the page stating: "Preview Mode. Accept the HIT to continue."
|
|
139
|
+
addPreviewSplash()
|
|
140
|
+
return
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Play the Graph:
|
|
144
|
+
const trace = await NodeKit.play(graph);
|
|
145
|
+
|
|
146
|
+
// Handle close down procedures, based on the inferred context:
|
|
147
|
+
if(turkContext && turkContext.previewMode === false){
|
|
148
|
+
submitToTurk(
|
|
149
|
+
trace,
|
|
150
|
+
turkContext.context.assignmentId,
|
|
151
|
+
turkContext.context.turkSubmitTo,
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
</script>
|
|
156
|
+
</head>
|
|
157
|
+
<body></body>
|
|
158
|
+
</html>
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import collections
|
|
2
|
+
from typing import Dict
|
|
3
|
+
|
|
4
|
+
from nodekit._internal.types.graph import Graph
|
|
5
|
+
from nodekit._internal.types.node import Node
|
|
6
|
+
from nodekit._internal.types.transition import End, Go, Transition
|
|
7
|
+
from nodekit._internal.types.value import NodeId
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# %%
|
|
11
|
+
def concat(
|
|
12
|
+
sequence: list[Graph | Node],
|
|
13
|
+
ids: list[str] | None = None,
|
|
14
|
+
) -> Graph:
|
|
15
|
+
"""
|
|
16
|
+
Returns a Graph which executes the given sequence of Node | Graph.
|
|
17
|
+
In the new Graph, the sequence items' RegisterIds and NodeIds are prepended ids '0', '1', ..., unless `ids` is given.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
if len(sequence) == 0:
|
|
21
|
+
raise ValueError("Sequence must have at least one item.")
|
|
22
|
+
|
|
23
|
+
# Generate new IDs:
|
|
24
|
+
if ids and len(ids) != len(sequence):
|
|
25
|
+
raise ValueError("If ids are given, must be the same length as sequence.")
|
|
26
|
+
if ids is None:
|
|
27
|
+
ids: list[NodeId] = [f"{i}" for i in range(len(sequence))]
|
|
28
|
+
if len(set(ids)) != len(ids):
|
|
29
|
+
counts = collections.Counter(ids)
|
|
30
|
+
duplicates = [id_ for id_, count in counts.items() if count > 1]
|
|
31
|
+
raise ValueError(
|
|
32
|
+
f"If ids are given, they must be unique. Duplicates:\n{'\n'.join(duplicates)}"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
# Assemble:
|
|
36
|
+
nodes: Dict[NodeId, Node | Graph] = {}
|
|
37
|
+
transitions: Dict[NodeId, Transition] = {}
|
|
38
|
+
|
|
39
|
+
for i, (node_id, node) in enumerate(zip(ids, sequence)):
|
|
40
|
+
nodes[node_id] = node
|
|
41
|
+
|
|
42
|
+
if i + 1 < len(ids):
|
|
43
|
+
transitions[node_id] = Go(to=ids[i + 1])
|
|
44
|
+
else:
|
|
45
|
+
transitions[node_id] = End()
|
|
46
|
+
|
|
47
|
+
return Graph(
|
|
48
|
+
nodes=nodes,
|
|
49
|
+
transitions=transitions,
|
|
50
|
+
start=ids[0],
|
|
51
|
+
)
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import contextlib
|
|
2
|
+
import zipfile
|
|
3
|
+
from typing import ContextManager, IO
|
|
4
|
+
|
|
5
|
+
from nodekit._internal.types.assets import (
|
|
6
|
+
AssetLocator,
|
|
7
|
+
URL,
|
|
8
|
+
RelativePath,
|
|
9
|
+
ZipArchiveInnerPath,
|
|
10
|
+
FileSystemPath,
|
|
11
|
+
Asset,
|
|
12
|
+
)
|
|
13
|
+
import urllib.request
|
|
14
|
+
import urllib.error
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
import shutil
|
|
19
|
+
import tempfile
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# %%
|
|
23
|
+
def open_asset(
|
|
24
|
+
asset: Asset,
|
|
25
|
+
) -> ContextManager[IO[bytes]]:
|
|
26
|
+
"""
|
|
27
|
+
Streams the bytes of the given Asset.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
locator: AssetLocator = asset.locator
|
|
31
|
+
|
|
32
|
+
if isinstance(locator, FileSystemPath):
|
|
33
|
+
return open(locator.path, "rb")
|
|
34
|
+
elif isinstance(locator, URL):
|
|
35
|
+
|
|
36
|
+
@contextlib.contextmanager
|
|
37
|
+
def open_url_stream():
|
|
38
|
+
req = urllib.request.Request(locator.url)
|
|
39
|
+
try:
|
|
40
|
+
# Add a timeout to avoid hanging forever:
|
|
41
|
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
42
|
+
# Raise on non-2xx if you want stricter behavior:
|
|
43
|
+
status = getattr(resp, "status", 200)
|
|
44
|
+
if not (200 <= status < 300):
|
|
45
|
+
raise urllib.error.HTTPError(
|
|
46
|
+
locator.url, status, "Bad HTTP status", resp.headers, None
|
|
47
|
+
)
|
|
48
|
+
yield resp # file-like, binary
|
|
49
|
+
except urllib.error.URLError as e:
|
|
50
|
+
raise RuntimeError(
|
|
51
|
+
f"Failed to stream Asset from URL: {locator.url}"
|
|
52
|
+
) from e
|
|
53
|
+
|
|
54
|
+
return open_url_stream()
|
|
55
|
+
|
|
56
|
+
elif isinstance(locator, ZipArchiveInnerPath):
|
|
57
|
+
|
|
58
|
+
@contextlib.contextmanager
|
|
59
|
+
def open_stream():
|
|
60
|
+
with zipfile.ZipFile(locator.zip_archive_path, "r") as zf:
|
|
61
|
+
with zf.open(str(locator.inner_path), "r") as fh:
|
|
62
|
+
yield fh
|
|
63
|
+
|
|
64
|
+
return open_stream()
|
|
65
|
+
elif isinstance(locator, RelativePath):
|
|
66
|
+
# Try to open relative to current working directory:
|
|
67
|
+
return open(locator.relative_path, "rb")
|
|
68
|
+
else:
|
|
69
|
+
raise ValueError(f"Unsupported locator type: {locator.locator_type}")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# %%
|
|
73
|
+
def save_asset(
|
|
74
|
+
asset: Asset,
|
|
75
|
+
path: Path,
|
|
76
|
+
) -> None:
|
|
77
|
+
"""
|
|
78
|
+
Persist `asset` bytes to `path` atomically.
|
|
79
|
+
Raises:
|
|
80
|
+
- FileExistsError if target exists and `overwrite=False`
|
|
81
|
+
- ValueError for mismatched extension when `add_extension=True`
|
|
82
|
+
"""
|
|
83
|
+
buffer_size: int = 1024 * 1024 # 1MB buffer for streaming copy
|
|
84
|
+
|
|
85
|
+
# --- ensure parent exists ---
|
|
86
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
87
|
+
|
|
88
|
+
# --- exists check ---
|
|
89
|
+
if path.exists():
|
|
90
|
+
raise FileExistsError(f"Refusing to overwrite existing file: {path}")
|
|
91
|
+
|
|
92
|
+
# Fast path: hardlink local file if possible
|
|
93
|
+
loc = asset.locator
|
|
94
|
+
if isinstance(loc, FileSystemPath):
|
|
95
|
+
src = Path(loc.path)
|
|
96
|
+
try:
|
|
97
|
+
# Hardlink avoids IO; falls back to copy path if cross-device
|
|
98
|
+
os.link(src, path)
|
|
99
|
+
except OSError:
|
|
100
|
+
# Cross-device or FS limitation: fall through to streamed copy
|
|
101
|
+
pass
|
|
102
|
+
return
|
|
103
|
+
|
|
104
|
+
# Slow path: stream
|
|
105
|
+
tmp_file_descriptor, tmp_path_str = tempfile.mkstemp(
|
|
106
|
+
prefix=path.name + ".", dir=path.parent
|
|
107
|
+
)
|
|
108
|
+
tmp_path = Path(tmp_path_str)
|
|
109
|
+
try:
|
|
110
|
+
with os.fdopen(tmp_file_descriptor, "wb", closefd=True) as out_f:
|
|
111
|
+
# Stream from source to temp file
|
|
112
|
+
with open_asset(asset) as in_f:
|
|
113
|
+
shutil.copyfileobj(in_f, out_f, length=buffer_size)
|
|
114
|
+
out_f.flush()
|
|
115
|
+
os.fsync(out_f.fileno())
|
|
116
|
+
|
|
117
|
+
# Atomic move into place (overwrites if exists)
|
|
118
|
+
os.replace(tmp_path, path)
|
|
119
|
+
finally:
|
|
120
|
+
# If something failed before replace, cleanup temp
|
|
121
|
+
if tmp_path.exists():
|
|
122
|
+
try:
|
|
123
|
+
tmp_path.unlink()
|
|
124
|
+
except OSError:
|
|
125
|
+
pass
|
|
File without changes
|