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
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
from abc import ABC
|
|
2
|
+
from typing import Literal, Annotated, Union, Self, Dict
|
|
3
|
+
|
|
4
|
+
import pydantic
|
|
5
|
+
|
|
6
|
+
from nodekit._internal.types.cards import Card
|
|
7
|
+
from nodekit._internal.types.value import (
|
|
8
|
+
PressableKey,
|
|
9
|
+
SpatialSize,
|
|
10
|
+
TimeDurationMsec,
|
|
11
|
+
)
|
|
12
|
+
from nodekit._internal.types.regions import Region
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# %%
|
|
16
|
+
class BaseSensor(pydantic.BaseModel, ABC):
|
|
17
|
+
"""
|
|
18
|
+
A Sensor is a listener for Participant behavior.
|
|
19
|
+
When a Sensor is triggered, it emits an Action and optionally applies an Outcome.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
sensor_type: str
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# %%
|
|
26
|
+
class WaitSensor(BaseSensor):
|
|
27
|
+
"""
|
|
28
|
+
A Sensor that triggers when the specified time has elapsed since the start of the Node.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
sensor_type: Literal["WaitSensor"] = "WaitSensor"
|
|
32
|
+
duration_msec: TimeDurationMsec = pydantic.Field(
|
|
33
|
+
description="The number of milliseconds from the start of the Node when the Sensor triggers.",
|
|
34
|
+
gt=0,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# %%
|
|
39
|
+
class ClickSensor(BaseSensor):
|
|
40
|
+
sensor_type: Literal["ClickSensor"] = "ClickSensor"
|
|
41
|
+
region: Region
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# %%
|
|
45
|
+
class KeySensor(BaseSensor):
|
|
46
|
+
sensor_type: Literal["KeySensor"] = "KeySensor"
|
|
47
|
+
keys: list[PressableKey] = pydantic.Field(
|
|
48
|
+
description="The keys that triggers the Sensor when pressed down."
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# %%
|
|
53
|
+
class SelectSensor(BaseSensor):
|
|
54
|
+
sensor_type: Literal["SelectSensor"] = "SelectSensor"
|
|
55
|
+
choices: Dict[str, Card]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# %%
|
|
59
|
+
class MultiSelectSensor(BaseSensor):
|
|
60
|
+
sensor_type: Literal["MultiSelectSensor"] = "MultiSelectSensor"
|
|
61
|
+
choices: Dict[str, Card]
|
|
62
|
+
|
|
63
|
+
min_selections: int = pydantic.Field(
|
|
64
|
+
ge=0,
|
|
65
|
+
description="The minimum number of Cards before the Sensor fires.",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
max_selections: int | None = pydantic.Field(
|
|
69
|
+
default=None,
|
|
70
|
+
validate_default=True,
|
|
71
|
+
ge=0,
|
|
72
|
+
description="If None, the selection can contain up to the number of available Cards.",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
confirm_button: Card
|
|
76
|
+
|
|
77
|
+
@pydantic.model_validator(mode="after")
|
|
78
|
+
def validate_selections_vals(self) -> Self:
|
|
79
|
+
if (
|
|
80
|
+
self.max_selections is not None
|
|
81
|
+
and self.max_selections < self.min_selections
|
|
82
|
+
):
|
|
83
|
+
raise pydantic.ValidationError(
|
|
84
|
+
f"max_selections ({self.max_selections}) must be greater than min_selections ({self.min_selections})",
|
|
85
|
+
)
|
|
86
|
+
return self
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# %%
|
|
90
|
+
class SliderSensor(BaseSensor):
|
|
91
|
+
sensor_type: Literal["SliderSensor"] = "SliderSensor"
|
|
92
|
+
num_bins: int = pydantic.Field(gt=1)
|
|
93
|
+
initial_bin_index: int
|
|
94
|
+
show_bin_markers: bool = True
|
|
95
|
+
orientation: Literal["horizontal", "vertical"] = "horizontal"
|
|
96
|
+
region: Region
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# %%
|
|
100
|
+
class TextEntrySensor(BaseSensor):
|
|
101
|
+
sensor_type: Literal["TextEntrySensor"] = "TextEntrySensor"
|
|
102
|
+
|
|
103
|
+
prompt: str = pydantic.Field(
|
|
104
|
+
description="The initial placeholder text shown in the free text response box. It disappears when the user selects the element.",
|
|
105
|
+
default="",
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
font_size: SpatialSize = pydantic.Field(
|
|
109
|
+
description="The height of the em-box, in Board units.",
|
|
110
|
+
default=0.02,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
min_length: int = pydantic.Field(
|
|
114
|
+
description="The minimum number of characters the user must enter before the Sensor fires. If None, no limit.",
|
|
115
|
+
default=1,
|
|
116
|
+
ge=1,
|
|
117
|
+
le=10000,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
max_length: int | None = pydantic.Field(
|
|
121
|
+
description="The maximum number of characters the user can enter. If None, no limit.",
|
|
122
|
+
default=None,
|
|
123
|
+
ge=1,
|
|
124
|
+
le=10000,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
region: Region
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# %%
|
|
131
|
+
class ProductSensor(BaseSensor):
|
|
132
|
+
sensor_type: Literal["ProductSensor"] = "ProductSensor"
|
|
133
|
+
children: Dict[str, "Sensor"]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# %%
|
|
137
|
+
class SumSensor(BaseSensor):
|
|
138
|
+
sensor_type: Literal["SumSensor"] = "SumSensor"
|
|
139
|
+
children: Dict[str, "Sensor"]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# %%
|
|
143
|
+
type Sensor = Annotated[
|
|
144
|
+
Union[
|
|
145
|
+
WaitSensor,
|
|
146
|
+
ClickSensor,
|
|
147
|
+
KeySensor,
|
|
148
|
+
SelectSensor,
|
|
149
|
+
MultiSelectSensor,
|
|
150
|
+
SliderSensor,
|
|
151
|
+
TextEntrySensor,
|
|
152
|
+
ProductSensor,
|
|
153
|
+
SumSensor,
|
|
154
|
+
],
|
|
155
|
+
pydantic.Field(discriminator="sensor_type"),
|
|
156
|
+
]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from typing import Literal
|
|
2
|
+
|
|
3
|
+
import pydantic
|
|
4
|
+
|
|
5
|
+
from nodekit import VERSION
|
|
6
|
+
from nodekit._internal.types.events.events import Event
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Trace(pydantic.BaseModel):
|
|
10
|
+
nodekit_version: Literal["0.2.0"] = pydantic.Field(
|
|
11
|
+
default=VERSION, validate_default=True
|
|
12
|
+
)
|
|
13
|
+
events: list[Event]
|
|
14
|
+
|
|
15
|
+
@pydantic.field_validator("events")
|
|
16
|
+
def order_events(cls, events: list[Event]) -> list[Event]:
|
|
17
|
+
return sorted(events, key=lambda e: e.t)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from typing import Dict, Literal, Annotated
|
|
2
|
+
|
|
3
|
+
import pydantic
|
|
4
|
+
|
|
5
|
+
from nodekit._internal.types.expressions.expressions import Expression
|
|
6
|
+
from nodekit._internal.types.value import NodeId, RegisterId, LeafValue
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
# %%
|
|
10
|
+
class BaseTransition(pydantic.BaseModel):
|
|
11
|
+
transition_type: str
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Go(BaseTransition):
|
|
15
|
+
transition_type: Literal["Go"] = "Go"
|
|
16
|
+
to: NodeId
|
|
17
|
+
register_updates: Dict[RegisterId, Expression] = pydantic.Field(
|
|
18
|
+
default_factory=dict,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class End(BaseTransition):
|
|
23
|
+
transition_type: Literal["End"] = "End"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
type LeafTransition = Go | End
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# %%
|
|
30
|
+
class IfThenElse(BaseTransition):
|
|
31
|
+
model_config = pydantic.ConfigDict(
|
|
32
|
+
serialize_by_alias=True,
|
|
33
|
+
validate_by_alias=False,
|
|
34
|
+
populate_by_name=True,
|
|
35
|
+
) # See https://docs.pydantic.dev/latest/api/config/#pydantic.config.ConfigDict.validate_by_name
|
|
36
|
+
|
|
37
|
+
transition_type: Literal["IfThenElse"] = "IfThenElse"
|
|
38
|
+
|
|
39
|
+
# Using Annotated to maintain type hints (https://docs.pydantic.dev/latest/concepts/fields/?query=populate_by_name#field-aliases)
|
|
40
|
+
if_: Annotated[
|
|
41
|
+
Expression,
|
|
42
|
+
pydantic.Field(
|
|
43
|
+
serialization_alias="if",
|
|
44
|
+
validation_alias="if",
|
|
45
|
+
description="A boolean-valued Expression.",
|
|
46
|
+
),
|
|
47
|
+
]
|
|
48
|
+
then: LeafTransition
|
|
49
|
+
else_: Annotated[
|
|
50
|
+
LeafTransition,
|
|
51
|
+
pydantic.Field(default_factory=End, validate_default=True, alias="else"),
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# %%
|
|
56
|
+
class Switch(BaseTransition):
|
|
57
|
+
transition_type: Literal["Switch"] = "Switch"
|
|
58
|
+
on: Expression
|
|
59
|
+
cases: Dict[LeafValue, LeafTransition]
|
|
60
|
+
default: LeafTransition = pydantic.Field(
|
|
61
|
+
default_factory=End,
|
|
62
|
+
description="The transition to take if no case matches.",
|
|
63
|
+
validate_default=True,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# %%
|
|
68
|
+
type Transition = Go | End | Switch | IfThenElse
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
from typing import Literal, Annotated
|
|
2
|
+
|
|
3
|
+
import pydantic
|
|
4
|
+
|
|
5
|
+
# %% Base Values
|
|
6
|
+
type Boolean = bool
|
|
7
|
+
type Integer = int
|
|
8
|
+
type Float = float
|
|
9
|
+
type String = str
|
|
10
|
+
|
|
11
|
+
# Containers
|
|
12
|
+
type List = list["Value"]
|
|
13
|
+
type Dict = dict[str, "Value"]
|
|
14
|
+
# Full Value
|
|
15
|
+
type LeafValue = Boolean | Integer | Float | String
|
|
16
|
+
type Value = LeafValue | List | Dict
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# %% Spatial
|
|
20
|
+
type SpatialSize = Annotated[
|
|
21
|
+
Float,
|
|
22
|
+
pydantic.Field(
|
|
23
|
+
strict=True,
|
|
24
|
+
ge=0,
|
|
25
|
+
le=1,
|
|
26
|
+
description="A spatial size relative to the smaller extent of the board (width or height, whichever is smaller). For example, a value of 0.5 corresponds to half the smaller extent of the board.",
|
|
27
|
+
),
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
type SpatialPoint = Annotated[Float, pydantic.Field(strict=True, ge=-0.5, le=0.5)]
|
|
31
|
+
|
|
32
|
+
type Mask = Annotated[
|
|
33
|
+
Literal["ellipse", "rectangle"],
|
|
34
|
+
pydantic.Field(
|
|
35
|
+
description='Describes the shape of a region inside of a bounding box. "rectangle" uses the box itself; "ellipse" inscribes a tightly fitted ellipse within the box.'
|
|
36
|
+
),
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
# %% Time
|
|
40
|
+
type TimeElapsedMsec = Annotated[
|
|
41
|
+
Integer,
|
|
42
|
+
pydantic.Field(
|
|
43
|
+
strict=True,
|
|
44
|
+
description="A time point, relative to some origin.",
|
|
45
|
+
),
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
type TimeDurationMsec = Annotated[
|
|
49
|
+
Integer,
|
|
50
|
+
pydantic.Field(
|
|
51
|
+
strict=True,
|
|
52
|
+
ge=0,
|
|
53
|
+
description="A duration of time in milliseconds, relative to the start of the Trace.",
|
|
54
|
+
),
|
|
55
|
+
]
|
|
56
|
+
# %% Text
|
|
57
|
+
type MarkdownString = Annotated[
|
|
58
|
+
str, pydantic.Field(strict=True, description="Markdown-formatted string")
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _normalize_hex_code(value: str) -> str:
|
|
63
|
+
if len(value) == 7:
|
|
64
|
+
# If the hex code is in the format #RRGGBB, append 'FF' for full opacity
|
|
65
|
+
value += "FF"
|
|
66
|
+
return value.lower() # Lowercase
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
type ColorHexString = Annotated[
|
|
70
|
+
String,
|
|
71
|
+
pydantic.BeforeValidator(_normalize_hex_code),
|
|
72
|
+
pydantic.Field(
|
|
73
|
+
pattern=r"^#[0-9a-fA-F]{8}$", # "#RRGGBBAA"
|
|
74
|
+
min_length=9,
|
|
75
|
+
max_length=9,
|
|
76
|
+
),
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
# %% Keyboard
|
|
80
|
+
PressableKey = Literal[
|
|
81
|
+
"Enter",
|
|
82
|
+
" ",
|
|
83
|
+
"ArrowDown",
|
|
84
|
+
"ArrowLeft",
|
|
85
|
+
"ArrowRight",
|
|
86
|
+
"ArrowUp",
|
|
87
|
+
"a",
|
|
88
|
+
"b",
|
|
89
|
+
"c",
|
|
90
|
+
"d",
|
|
91
|
+
"e",
|
|
92
|
+
"f",
|
|
93
|
+
"g",
|
|
94
|
+
"h",
|
|
95
|
+
"i",
|
|
96
|
+
"j",
|
|
97
|
+
"k",
|
|
98
|
+
"l",
|
|
99
|
+
"m",
|
|
100
|
+
"n",
|
|
101
|
+
"o",
|
|
102
|
+
"p",
|
|
103
|
+
"q",
|
|
104
|
+
"r",
|
|
105
|
+
"s",
|
|
106
|
+
"t",
|
|
107
|
+
"u",
|
|
108
|
+
"v",
|
|
109
|
+
"w",
|
|
110
|
+
"x",
|
|
111
|
+
"y",
|
|
112
|
+
"z",
|
|
113
|
+
"0",
|
|
114
|
+
"1",
|
|
115
|
+
"2",
|
|
116
|
+
"3",
|
|
117
|
+
"4",
|
|
118
|
+
"5",
|
|
119
|
+
"6",
|
|
120
|
+
"7",
|
|
121
|
+
"8",
|
|
122
|
+
"9",
|
|
123
|
+
]
|
|
124
|
+
|
|
125
|
+
# %% Assets
|
|
126
|
+
type SHA256 = Annotated[String, pydantic.Field(pattern=r"^[a-f0-9]{64}$")]
|
|
127
|
+
"""A hex string representing a SHA-256 hash.
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
type ImageMediaType = Literal["image/png", "image/svg+xml"]
|
|
131
|
+
type VideoMediaType = Literal["video/mp4"]
|
|
132
|
+
type MediaType = ImageMediaType | VideoMediaType
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# %% Identifiers
|
|
136
|
+
type NodeId = Annotated[
|
|
137
|
+
String,
|
|
138
|
+
pydantic.Field(
|
|
139
|
+
description="An identifier for a Node which is unique within a Graph.",
|
|
140
|
+
),
|
|
141
|
+
]
|
|
142
|
+
|
|
143
|
+
type RegisterId = Annotated[
|
|
144
|
+
String, pydantic.Field(description="An identifier for a Graph register.")
|
|
145
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import importlib.resources
|
|
2
|
+
from functools import lru_cache
|
|
3
|
+
|
|
4
|
+
import pydantic
|
|
5
|
+
from nodekit._internal.utils.hashing import hash_string
|
|
6
|
+
from nodekit._internal.types.value import SHA256
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
# %%
|
|
10
|
+
class NodeKitBrowserBundle(pydantic.BaseModel):
|
|
11
|
+
css: str
|
|
12
|
+
css_sha256: SHA256
|
|
13
|
+
|
|
14
|
+
js: str
|
|
15
|
+
js_sha256: SHA256
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@lru_cache(maxsize=1)
|
|
19
|
+
def get_browser_bundle() -> NodeKitBrowserBundle:
|
|
20
|
+
css_file = importlib.resources.files("nodekit") / "_static" / "nodekit.css"
|
|
21
|
+
js_file = importlib.resources.files("nodekit") / "_static" / "nodekit.js"
|
|
22
|
+
|
|
23
|
+
css_string = css_file.read_text()
|
|
24
|
+
js_string = js_file.read_text()
|
|
25
|
+
|
|
26
|
+
# Compute hashes:
|
|
27
|
+
css_sha256 = hash_string(css_string)
|
|
28
|
+
js_sha256 = hash_string(js_string)
|
|
29
|
+
|
|
30
|
+
return NodeKitBrowserBundle(
|
|
31
|
+
css=css_string,
|
|
32
|
+
css_sha256=css_sha256,
|
|
33
|
+
js=js_string,
|
|
34
|
+
js_sha256=js_sha256,
|
|
35
|
+
)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from nodekit._internal.types.value import MediaType
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def get_extension_from_media_type(media_type: MediaType) -> str:
|
|
5
|
+
"""
|
|
6
|
+
Returns the file extension, without the leading dot, for a given media (MIME) type.
|
|
7
|
+
"""
|
|
8
|
+
mime_to_extension = {
|
|
9
|
+
"image/png": "png",
|
|
10
|
+
"image/svg+xml": "svg",
|
|
11
|
+
"video/mp4": "mp4",
|
|
12
|
+
}
|
|
13
|
+
if media_type not in mime_to_extension:
|
|
14
|
+
raise ValueError(f"Unsupported media type: {media_type}")
|
|
15
|
+
return mime_to_extension[media_type]
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from nodekit._internal.types.value import SHA256
|
|
2
|
+
import pydantic
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import BinaryIO
|
|
5
|
+
import hashlib
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def hash_file(path: Path) -> SHA256:
|
|
9
|
+
"""
|
|
10
|
+
Compute the SHA-256 hash of a file at the given path.
|
|
11
|
+
"""
|
|
12
|
+
with path.open("rb") as f:
|
|
13
|
+
return hash_byte_stream(f)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def hash_byte_stream(byte_stream: BinaryIO) -> SHA256:
|
|
17
|
+
"""
|
|
18
|
+
Compute the SHA-256 hash of a byte stream.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
# Reset stream to the beginning
|
|
22
|
+
initial_position = byte_stream.tell()
|
|
23
|
+
byte_stream.seek(0)
|
|
24
|
+
h = hashlib.sha256()
|
|
25
|
+
for chunk in iter(lambda: byte_stream.read(1024 * 1024), b""): # 1 MB chunks
|
|
26
|
+
h.update(chunk)
|
|
27
|
+
byte_stream.seek(initial_position) # Reset stream to the beginning again
|
|
28
|
+
|
|
29
|
+
sha256_hexdigest = h.hexdigest()
|
|
30
|
+
type_adapter = pydantic.TypeAdapter(SHA256)
|
|
31
|
+
validated_sha256 = type_adapter.validate_python(sha256_hexdigest)
|
|
32
|
+
|
|
33
|
+
return validated_sha256
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def hash_string(s: str) -> SHA256:
|
|
37
|
+
"""
|
|
38
|
+
Compute the SHA-256 hash of a string.
|
|
39
|
+
"""
|
|
40
|
+
h = hashlib.sha256()
|
|
41
|
+
h.update(s.encode("utf-8"))
|
|
42
|
+
sha256_hexdigest = h.hexdigest()
|
|
43
|
+
type_adapter = pydantic.TypeAdapter(SHA256)
|
|
44
|
+
validated_sha256 = type_adapter.validate_python(sha256_hexdigest)
|
|
45
|
+
|
|
46
|
+
return validated_sha256
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from typing import Iterator, Iterable
|
|
2
|
+
|
|
3
|
+
from nodekit._internal.types.assets import Image, Video
|
|
4
|
+
from nodekit._internal.types.cards import Card, ImageCard, VideoCard, CompositeCard
|
|
5
|
+
from nodekit._internal.types.graph import Graph
|
|
6
|
+
from nodekit._internal.types.node import Node
|
|
7
|
+
from nodekit._internal.types.sensors.sensors import (
|
|
8
|
+
SelectSensor,
|
|
9
|
+
MultiSelectSensor,
|
|
10
|
+
ProductSensor,
|
|
11
|
+
SumSensor,
|
|
12
|
+
Sensor,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# %%
|
|
17
|
+
def iter_assets(graph: Graph) -> Iterator[Image | Video]:
|
|
18
|
+
"""
|
|
19
|
+
Iterates over all assets found in the graph (cards and sensor-attached cards).
|
|
20
|
+
"""
|
|
21
|
+
for node in graph.nodes.values():
|
|
22
|
+
if isinstance(node, Graph):
|
|
23
|
+
yield from iter_assets(node)
|
|
24
|
+
continue
|
|
25
|
+
elif isinstance(node, Node):
|
|
26
|
+
if node.stimulus is not None:
|
|
27
|
+
yield from _iter_card_assets(node.stimulus)
|
|
28
|
+
|
|
29
|
+
# Some sensors carry cards (select/multiselect choices, products/sums).
|
|
30
|
+
|
|
31
|
+
yield from _iter_sensor_cards(node.sensor)
|
|
32
|
+
else:
|
|
33
|
+
raise TypeError(f"Unexpected graph node type: {type(node)}")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _iter_card_assets(card: Card) -> Iterable[Image | Video]:
|
|
37
|
+
if isinstance(card, ImageCard):
|
|
38
|
+
yield card.image
|
|
39
|
+
elif isinstance(card, VideoCard):
|
|
40
|
+
yield card.video
|
|
41
|
+
elif isinstance(card, CompositeCard):
|
|
42
|
+
for child in card.children.values():
|
|
43
|
+
yield from _iter_card_assets(child)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _iter_sensor_cards(sensor: Sensor) -> Iterable[Image | Video]:
|
|
47
|
+
"""
|
|
48
|
+
Helper to walk sensor trees for cards (select/multiselect/product/sum).
|
|
49
|
+
"""
|
|
50
|
+
if isinstance(sensor, SelectSensor):
|
|
51
|
+
for choice_card in sensor.choices.values():
|
|
52
|
+
yield from _iter_card_assets(choice_card)
|
|
53
|
+
elif isinstance(sensor, MultiSelectSensor):
|
|
54
|
+
for choice_card in sensor.choices.values():
|
|
55
|
+
yield from _iter_card_assets(choice_card)
|
|
56
|
+
elif isinstance(sensor, ProductSensor):
|
|
57
|
+
for child in sensor.children.values():
|
|
58
|
+
yield from _iter_sensor_cards(child)
|
|
59
|
+
elif isinstance(sensor, SumSensor):
|
|
60
|
+
for child in sensor.children.values():
|
|
61
|
+
yield from _iter_sensor_cards(child)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
VERSION = "0.2.0"
|