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.
Files changed (62) hide show
  1. nodekit/.DS_Store +0 -0
  2. nodekit/__init__.py +53 -0
  3. nodekit/_internal/__init__.py +0 -0
  4. nodekit/_internal/ops/__init__.py +0 -0
  5. nodekit/_internal/ops/build_site/__init__.py +117 -0
  6. nodekit/_internal/ops/build_site/harness.j2 +158 -0
  7. nodekit/_internal/ops/concat.py +51 -0
  8. nodekit/_internal/ops/open_asset_save_asset.py +125 -0
  9. nodekit/_internal/ops/play/__init__.py +4 -0
  10. nodekit/_internal/ops/play/local_runner/__init__.py +0 -0
  11. nodekit/_internal/ops/play/local_runner/main.py +234 -0
  12. nodekit/_internal/ops/play/local_runner/site-template.j2 +38 -0
  13. nodekit/_internal/ops/save_graph_load_graph.py +131 -0
  14. nodekit/_internal/ops/topological_sorting.py +122 -0
  15. nodekit/_internal/types/__init__.py +0 -0
  16. nodekit/_internal/types/actions/__init__.py +0 -0
  17. nodekit/_internal/types/actions/actions.py +89 -0
  18. nodekit/_internal/types/assets/__init__.py +151 -0
  19. nodekit/_internal/types/cards/__init__.py +85 -0
  20. nodekit/_internal/types/events/__init__.py +0 -0
  21. nodekit/_internal/types/events/events.py +145 -0
  22. nodekit/_internal/types/expressions/__init__.py +0 -0
  23. nodekit/_internal/types/expressions/expressions.py +242 -0
  24. nodekit/_internal/types/graph.py +42 -0
  25. nodekit/_internal/types/node.py +21 -0
  26. nodekit/_internal/types/regions/__init__.py +13 -0
  27. nodekit/_internal/types/sensors/__init__.py +0 -0
  28. nodekit/_internal/types/sensors/sensors.py +156 -0
  29. nodekit/_internal/types/trace.py +17 -0
  30. nodekit/_internal/types/transition.py +68 -0
  31. nodekit/_internal/types/value.py +145 -0
  32. nodekit/_internal/utils/__init__.py +0 -0
  33. nodekit/_internal/utils/get_browser_bundle.py +35 -0
  34. nodekit/_internal/utils/get_extension_from_media_type.py +15 -0
  35. nodekit/_internal/utils/hashing.py +46 -0
  36. nodekit/_internal/utils/iter_assets.py +61 -0
  37. nodekit/_internal/version.py +1 -0
  38. nodekit/_static/nodekit.css +10 -0
  39. nodekit/_static/nodekit.js +59 -0
  40. nodekit/actions/__init__.py +25 -0
  41. nodekit/assets/__init__.py +7 -0
  42. nodekit/cards/__init__.py +15 -0
  43. nodekit/events/__init__.py +30 -0
  44. nodekit/experimental/.DS_Store +0 -0
  45. nodekit/experimental/__init__.py +0 -0
  46. nodekit/experimental/recruitment_services/__init__.py +0 -0
  47. nodekit/experimental/recruitment_services/base.py +77 -0
  48. nodekit/experimental/recruitment_services/mechanical_turk/__init__.py +0 -0
  49. nodekit/experimental/recruitment_services/mechanical_turk/client.py +359 -0
  50. nodekit/experimental/recruitment_services/mechanical_turk/models.py +116 -0
  51. nodekit/experimental/s3.py +219 -0
  52. nodekit/experimental/turk_helper.py +223 -0
  53. nodekit/experimental/visualization/.DS_Store +0 -0
  54. nodekit/experimental/visualization/__init__.py +0 -0
  55. nodekit/experimental/visualization/pointer.py +443 -0
  56. nodekit/expressions/__init__.py +55 -0
  57. nodekit/sensors/__init__.py +25 -0
  58. nodekit/transitions/__init__.py +15 -0
  59. nodekit/values/__init__.py +63 -0
  60. nodekit-0.2.0.dist-info/METADATA +221 -0
  61. nodekit-0.2.0.dist-info/RECORD +62 -0
  62. 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"