nodekit 0.0.1a1__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.

Potentially problematic release.


This version of nodekit might be problematic. Click here for more details.

Files changed (50) hide show
  1. nodekit/__init__.py +46 -0
  2. nodekit/_internal/__init__.py +0 -0
  3. nodekit/_internal/compilers/__init__.py +0 -0
  4. nodekit/_internal/compilers/events.py +59 -0
  5. nodekit/_internal/compilers/html_rendering.py +44 -0
  6. nodekit/_internal/compilers/node_graph_site_template.j2 +109 -0
  7. nodekit/_internal/models/__init__.py +0 -0
  8. nodekit/_internal/models/assets/__init__.py +0 -0
  9. nodekit/_internal/models/assets/base.py +43 -0
  10. nodekit/_internal/models/fields.py +60 -0
  11. nodekit/_internal/models/node_engine/__init__.py +0 -0
  12. nodekit/_internal/models/node_engine/base.py +22 -0
  13. nodekit/_internal/models/node_engine/board.py +9 -0
  14. nodekit/_internal/models/node_engine/bonus_policy.py +50 -0
  15. nodekit/_internal/models/node_engine/cards/__init__.py +0 -0
  16. nodekit/_internal/models/node_engine/cards/base.py +25 -0
  17. nodekit/_internal/models/node_engine/cards/cards.py +60 -0
  18. nodekit/_internal/models/node_engine/effects/__init__.py +0 -0
  19. nodekit/_internal/models/node_engine/effects/base.py +30 -0
  20. nodekit/_internal/models/node_engine/fields.py +85 -0
  21. nodekit/_internal/models/node_engine/node_graph.py +118 -0
  22. nodekit/_internal/models/node_engine/reinforcer_maps/__init__.py +0 -0
  23. nodekit/_internal/models/node_engine/reinforcer_maps/base.py +14 -0
  24. nodekit/_internal/models/node_engine/reinforcer_maps/reinforcer/__init__.py +0 -0
  25. nodekit/_internal/models/node_engine/reinforcer_maps/reinforcer/reinforcer.py +14 -0
  26. nodekit/_internal/models/node_engine/reinforcer_maps/reinforcer_maps.py +43 -0
  27. nodekit/_internal/models/node_engine/runtime_metrics.py +24 -0
  28. nodekit/_internal/models/node_engine/sensors/__init__.py +0 -0
  29. nodekit/_internal/models/node_engine/sensors/actions/__init__.py +0 -0
  30. nodekit/_internal/models/node_engine/sensors/actions/actions.py +48 -0
  31. nodekit/_internal/models/node_engine/sensors/actions/base.py +18 -0
  32. nodekit/_internal/models/node_engine/sensors/base.py +25 -0
  33. nodekit/_internal/models/node_engine/sensors/sensors.py +49 -0
  34. nodekit/_internal/rule_engine/__init__.py +0 -0
  35. nodekit/_internal/rule_engine/compute_bonus.py +32 -0
  36. nodekit/actions/__init__.py +13 -0
  37. nodekit/assets/__init__.py +9 -0
  38. nodekit/bonus_rules/__init__.py +10 -0
  39. nodekit/cards/__init__.py +13 -0
  40. nodekit/compile/__init__.py +10 -0
  41. nodekit/effects/__init__.py +7 -0
  42. nodekit/events/__init__.py +23 -0
  43. nodekit/reinforcer_maps/__init__.py +10 -0
  44. nodekit/sensors/__init__.py +12 -0
  45. nodekit/types/__init__.py +27 -0
  46. nodekit-0.0.1a1.dist-info/METADATA +217 -0
  47. nodekit-0.0.1a1.dist-info/RECORD +50 -0
  48. nodekit-0.0.1a1.dist-info/WHEEL +5 -0
  49. nodekit-0.0.1a1.dist-info/licenses/LICENSE +201 -0
  50. nodekit-0.0.1a1.dist-info/top_level.txt +1 -0
nodekit/__init__.py ADDED
@@ -0,0 +1,46 @@
1
+ # This module exposes the public API for the psykit package.
2
+
3
+ __all__ = [
4
+ 'VERSION',
5
+
6
+ # Top-level models:
7
+ 'NodeGraph',
8
+ 'Node',
9
+ 'NodeResult',
10
+
11
+ # Namespaced models:
12
+ 'actions',
13
+ 'assets',
14
+ 'cards',
15
+ 'effects',
16
+ 'reinforcer_maps',
17
+ 'sensors',
18
+ 'bonus_rules',
19
+ 'events',
20
+
21
+ # Types:
22
+ 'types',
23
+
24
+ # Runtime API:
25
+ 'compile',
26
+ ]
27
+
28
+ # Incoming models:
29
+ from nodekit._internal.models.node_engine.node_graph import (
30
+ NodeGraph,
31
+ Node,
32
+ NodeResult
33
+ )
34
+
35
+ import nodekit.cards as cards
36
+ import nodekit.assets as assets
37
+ import nodekit.effects as effects
38
+ import nodekit.reinforcer_maps as reinforcer_maps
39
+ import nodekit.sensors as sensors
40
+ import nodekit.actions as actions
41
+ import nodekit.bonus_rules as bonus_rules
42
+ import nodekit.types as types
43
+ import nodekit.compile as compile
44
+ import nodekit.events as events
45
+
46
+ VERSION = '0.0.1'
File without changes
File without changes
@@ -0,0 +1,59 @@
1
+ from typing import Literal, Annotated, Union
2
+
3
+ import pydantic
4
+
5
+ from nodekit._internal.models.fields import DatetimeUTC
6
+ from nodekit._internal.models.node_engine.base import NullValue
7
+ from nodekit._internal.models.node_engine.node_graph import NodeResult
8
+
9
+ from uuid import UUID
10
+
11
+
12
+ # %%
13
+ import enum
14
+ class EventTypeEnum(str, enum.Enum):
15
+ StartEvent = 'StartEvent'
16
+ NodeResultEvent = 'NodeResultEvent'
17
+ EndEvent = 'EndEvent'
18
+
19
+ # %%
20
+ class BaseEvent(pydantic.BaseModel):
21
+ event_id: UUID
22
+ run_id: UUID
23
+ event_type: EventTypeEnum
24
+ event_payload: NullValue
25
+ event_timestamp: DatetimeUTC
26
+
27
+
28
+ # %%
29
+ class StartEvent(BaseEvent):
30
+ event_type: Literal[EventTypeEnum.StartEvent] = EventTypeEnum.StartEvent
31
+
32
+
33
+ class EndEvent(BaseEvent):
34
+ event_type: Literal[EventTypeEnum.EndEvent] = EventTypeEnum.EndEvent
35
+
36
+
37
+ # %%
38
+ class NodeResultEvent(BaseEvent):
39
+ event_type: Literal[EventTypeEnum.NodeResultEvent] = EventTypeEnum.NodeResultEvent
40
+ event_payload: NodeResult
41
+
42
+ # %%
43
+ Event = Annotated[
44
+ Union[
45
+ StartEvent,
46
+ NodeResultEvent,
47
+ EndEvent,
48
+ ],
49
+ pydantic.Field(discriminator='event_type')
50
+ ]
51
+
52
+
53
+ # %% Expected server communication protocol:
54
+ SubmitEventRequest = Event
55
+
56
+ class SubmitEventResponse(pydantic.BaseModel):
57
+ redirect_url: str | None = pydantic.Field(
58
+ description="The URL to which the Participant browser should redirect after submitting this Event. If None, no redirect is needed.",
59
+ )
@@ -0,0 +1,44 @@
1
+ from pathlib import Path
2
+
3
+ import jinja2
4
+
5
+ from nodekit._internal.models.node_engine.node_graph import NodeGraph
6
+ import pydantic
7
+
8
+
9
+ # %%
10
+ class CompileHtmlOptions(pydantic.BaseModel):
11
+ event_submission_url: str | None = pydantic.Field(
12
+ description='An endpoint to which Events will be sent. If None, the Events will simply be shown at the end of the Run.',
13
+ default = None,
14
+ )
15
+ start_node_execution_index: int
16
+
17
+ run_id: str
18
+
19
+
20
+ def html(
21
+ node_graph: NodeGraph,
22
+ options: CompileHtmlOptions | None = None,
23
+ ) -> str:
24
+ if options is None:
25
+ options = CompileHtmlOptions(
26
+ event_submission_url=None,
27
+ start_node_execution_index=0,
28
+ run_id='NO_RUN_ID',
29
+ )
30
+
31
+ # Render the node sequence using a Jinja2 template
32
+ template_location = Path(__file__).parent / 'node_graph_site_template.j2'
33
+ template = jinja2.Environment(loader=jinja2.FileSystemLoader(template_location.parent)).get_template(template_location.name)
34
+
35
+ html_string = template.render(
36
+ dict(
37
+ node_graph=node_graph.model_dump(mode='json'),
38
+ event_submission_url=options.event_submission_url,
39
+ run_id=options.run_id,
40
+ start_node_execution_index=options.start_node_execution_index,
41
+ )
42
+ )
43
+
44
+ return html_string
@@ -0,0 +1,109 @@
1
+ <!doctype html>
2
+ <html lang="eng">
3
+
4
+ <head>
5
+ <title>NodeGraph</title>
6
+ <script src="https://unpkg.com/nodekit-browser@0.0.1/dist/nodekit.js"></script>
7
+ <script src="https://unpkg.com/@psychoscope/node-player@0.2.2/dist/node-player.js"></script>
8
+ <link rel="stylesheet" href="https://unpkg.com/@psychoscope/node-player@0.2.2/dist/node-player.css">
9
+ <script>
10
+
11
+ // Rendering context:
12
+ const nodeGraph = {{ node_graph | tojson(indent=4) | indent(8, first=False) }};
13
+ const event_submission_url = {{ event_submission_url | tojson}};
14
+ const run_id = "{{ run_id }}";
15
+ const start_node_execution_index = {{ start_node_execution_index }};
16
+
17
+ // Run:
18
+ window.onload = async () => {
19
+ let eventClient = null;
20
+ if (event_submission_url) {
21
+ eventClient = new EventClient(
22
+ run_id,
23
+ event_submission_url
24
+ );
25
+ await eventClient.sendStartEvent()
26
+ }
27
+
28
+ // Play the Nodes in the NodeGraph:
29
+ const nodes = nodeGraph.nodes;
30
+ let nodePlayer = new NodePlayer();
31
+ const nodeResultsBuffer = [];
32
+ for (let i = start_node_execution_index; i < nodes.length; i++) {
33
+ const node = nodes[i];
34
+ const nodePlayId = await nodePlayer.prepare(node);
35
+ let nodeMeasurements = await nodePlayer.play(nodePlayId);
36
+
37
+ // Update the progress bar:
38
+ nodePlayer.setProgressBar((i + 1) / nodes.length * 100);
39
+
40
+ // Package the NodeResult:
41
+ const nodeResult = {
42
+ node_id: node.node_id,
43
+ timestamp_start: nodeMeasurements.timestamp_node_started,
44
+ timestamp_end: nodeMeasurements.timestamp_node_completed,
45
+ node_execution_index: i,
46
+ action: nodeMeasurements.action,
47
+ runtime_metrics: nodeMeasurements.runtime_metrics,
48
+ };
49
+
50
+ // Queue the NodeResult being sent to the server:
51
+ if (eventClient) {
52
+ // Fire and forget
53
+ eventClient.sendNodeResultEvent(nodeResult)
54
+ }
55
+
56
+ nodeResultsBuffer.push(nodeResult);
57
+ }
58
+
59
+ // Compute and disclose the provisional result of the bonus amount:
60
+ const bonusRules = nodeGraph.bonus_rules;
61
+ let bonusComputed = 0;
62
+ for (let i = 0; i < nodeResultsBuffer.length; i++) {
63
+ const action = nodeResultsBuffer[i].action;
64
+
65
+ // Run bonus rule engine on this NodeResult:
66
+ for (let ruleIndex = 0; ruleIndex < bonusRules.length; ruleIndex++) {
67
+ const rule = bonusRules[ruleIndex];
68
+ // Dynamic dispatch:
69
+ if (rule.bonus_rule_type === 'ConstantBonusRule') {
70
+ const parameters = rule.bonus_rule_parameters;
71
+ if (parameters.sensor_id === action.sensor_id) {
72
+ bonusComputed += Number(parameters.bonus_amount_usd);
73
+ }
74
+ }
75
+ }
76
+ }
77
+ bonusComputed = Math.max(0, bonusComputed);
78
+ bonusComputed = Math.round(bonusComputed * 100) / 100; // Round to 2 decimals
79
+
80
+ // Play the end screen:
81
+ let bonusMessage = '';
82
+ if (bonusComputed > 0) {
83
+ bonusMessage = `Bonus: ${bonusComputed} USD (pending validation)`;
84
+ }
85
+ await nodePlayer.playEndScreen(
86
+ bonusMessage,
87
+ )
88
+
89
+ // Close server connection and possibly redirect:
90
+ if (eventClient) {
91
+ let endResponse = await eventClient.sendEndEvent();
92
+ console.log('end response', endResponse)
93
+ if (endResponse.redirect_url) {
94
+ window.location.replace(endResponse.redirect_url);
95
+ }
96
+ }
97
+
98
+ // If no redirect occurred, show the NodeResults:
99
+ nodePlayer.showConsoleMessageOverlay(
100
+ 'NodeResults',
101
+ nodeResultsBuffer
102
+ );
103
+ };
104
+ </script>
105
+ </head>
106
+ <body></body>
107
+ </html>
108
+
109
+
File without changes
File without changes
@@ -0,0 +1,43 @@
1
+ import mimetypes
2
+ from abc import ABC
3
+ from typing import Self, Annotated, Union, Literal
4
+
5
+ import pydantic
6
+
7
+ from nodekit._internal.models.fields import SHA256, MimeType
8
+
9
+
10
+ # %%
11
+ class BaseAssetLink(pydantic.BaseModel, ABC):
12
+ mime_type: MimeType
13
+ sha256: SHA256
14
+ asset_url: pydantic.AnyHttpUrl
15
+
16
+ @pydantic.model_validator(mode='after')
17
+ def check_url(self) -> Self:
18
+ """
19
+ Validate that the URL ends with the expected file extension
20
+ """
21
+ extension = mimetypes.guess_extension(type=self.mime_type, strict=True)
22
+ if not extension:
23
+ raise ValueError(f"Could not determine file extension for mime type {self.mime_type}.")
24
+
25
+ if not str(self.asset_url).endswith(extension):
26
+ raise ValueError(f"AssetLink {self.asset_url} does not end with the expected file extension {extension}.")
27
+
28
+ return self
29
+
30
+
31
+ # %%
32
+ class ImageLink(BaseAssetLink):
33
+ mime_type: Literal['image/png'] = 'image/png'
34
+
35
+
36
+ # %%
37
+ AssetLink = Annotated[
38
+ Union[
39
+ ImageLink,
40
+ # Add other AssetLink types here as needed
41
+ ],
42
+ pydantic.Field(discriminator='mime_type')
43
+ ]
@@ -0,0 +1,60 @@
1
+ import datetime
2
+ from decimal import Decimal
3
+ from typing import Literal, Annotated
4
+
5
+ import pydantic
6
+
7
+
8
+ # %%
9
+ def _ensure_monetary_amount_precision(value: str) -> str:
10
+ SubcentMonetaryAmountAdapter = pydantic.TypeAdapter(
11
+ Annotated[Decimal, pydantic.Field(decimal_places=5)]
12
+ )
13
+ d = SubcentMonetaryAmountAdapter.validate_python(value)
14
+ return str(d)
15
+
16
+
17
+ MonetaryAmountUsd = Annotated[
18
+ str,
19
+ pydantic.Field(description='An arbitrary amount of money in USD, including negative amounts, represented as a string with at most five decimal places, e.g., "1.00001".'),
20
+ pydantic.AfterValidator(_ensure_monetary_amount_precision)
21
+ ]
22
+
23
+
24
+ # %%
25
+ def _ensure_payable_monetary_amount(value: str) -> str:
26
+ PayableMonetaryAmountAdapter = pydantic.TypeAdapter(
27
+ Annotated[Decimal, pydantic.Field(decimal_places=5)]
28
+ )
29
+ d = PayableMonetaryAmountAdapter.validate_python(value)
30
+ return str(d)
31
+
32
+
33
+ PayableMonetaryAmountUsd = Annotated[
34
+ str,
35
+ pydantic.Field(description='A semi-positive amount of money in USD that is payable to a worker, represented as a string with at most two decimal places, e.g., "1.00". This amount must be at least "0.01".'),
36
+ pydantic.AfterValidator(_ensure_payable_monetary_amount)
37
+ ]
38
+
39
+
40
+ # %% Timestamps
41
+ def ensure_utc(t: datetime.datetime) -> datetime.datetime:
42
+ # Ensures that a datetime is timezone-aware and in UTC.
43
+ if t.tzinfo is None:
44
+ raise ValueError(f"Datetime must be timezone-aware: {t}")
45
+ return t.astimezone(datetime.timezone.utc)
46
+
47
+
48
+ DatetimeUTC = Annotated[
49
+ datetime.datetime,
50
+ pydantic.Field(description='A timezone-aware datetime in UTC.'),
51
+ pydantic.AfterValidator(ensure_utc)
52
+ ]
53
+
54
+ # %%
55
+ SHA256 = Annotated[str, pydantic.Field(pattern=r'^[a-f0-9]{64}$')]
56
+
57
+ MimeType = Literal[
58
+ 'image/png',
59
+ # Add other supported mime types here as needed
60
+ ]
File without changes
@@ -0,0 +1,22 @@
1
+ import pydantic
2
+
3
+
4
+ class DslModel(pydantic.BaseModel):
5
+ pass
6
+
7
+
8
+
9
+ class NullParameters(DslModel):
10
+ """
11
+ A sentinel model for *_parameter fields which do not require specification.
12
+ """
13
+ pass
14
+
15
+
16
+ class NullValue(DslModel):
17
+ """
18
+ A sentinel model for *_value fields which do not require specification.
19
+ """
20
+ pass
21
+
22
+
@@ -0,0 +1,9 @@
1
+ import pydantic
2
+
3
+ from nodekit._internal.models.node_engine.base import DslModel
4
+
5
+
6
+ # %% Board
7
+ class Board(DslModel):
8
+ board_width_px: int = pydantic.Field(default=768, gt=0)
9
+ board_height_px: int = pydantic.Field(default=768, gt=0)
@@ -0,0 +1,50 @@
1
+ from abc import ABC
2
+ from typing import Dict, Any, Annotated, Union, List, Literal
3
+
4
+ import pydantic
5
+
6
+ from nodekit._internal.models.node_engine.fields import SensorId
7
+ from nodekit._internal.models.fields import (
8
+ MonetaryAmountUsd,
9
+ )
10
+
11
+
12
+ # %%
13
+ class BaseBonusRule(pydantic.BaseModel, ABC):
14
+ bonus_rule_type: str
15
+ bonus_rule_parameters: Dict[str, Any]
16
+
17
+
18
+ class ConstantBonusRule(BaseBonusRule):
19
+ """
20
+ A bonus rule that applies a bonus whenever a particular Sensor is triggered, regardless of the Action's value.
21
+ """
22
+ bonus_rule_type: Literal['ConstantBonusRule'] = 'ConstantBonusRule'
23
+
24
+ class Parameters(pydantic.BaseModel):
25
+ """
26
+ Parameters for the ConstantBonusRule.
27
+ """
28
+ sensor_id: SensorId = pydantic.Field(
29
+ description='The ID of the sensor to which this bonus rule applies.'
30
+ )
31
+ bonus_amount_usd: MonetaryAmountUsd = pydantic.Field(
32
+ description='The change in bonus amount to apply when the sensor is triggered. Can be positive or negative.'
33
+ )
34
+
35
+ bonus_rule_parameters: Parameters
36
+
37
+
38
+ # %%
39
+ BonusRule = Annotated[
40
+ Union[ConstantBonusRule],
41
+ pydantic.Field(
42
+ discriminator='bonus_rule_type',
43
+ description='The type of bonus rule to apply.'
44
+ )
45
+ ]
46
+
47
+
48
+ # %%
49
+ class BonusPolicy(pydantic.BaseModel):
50
+ bonus_rules: List[BonusRule] = pydantic.Field(default_factory=list)
File without changes
@@ -0,0 +1,25 @@
1
+ from abc import ABC
2
+ from typing import Any
3
+
4
+ import pydantic
5
+
6
+ from nodekit._internal.models.node_engine.base import DslModel
7
+ from nodekit._internal.models.node_engine.fields import BoardRectangle, BoardLocation, Timespan, CardId
8
+ from uuid import uuid4
9
+
10
+
11
+ class BaseCard(DslModel, ABC):
12
+ """
13
+ Cards are the atomic elements which constitute a single Node.
14
+ Cards are spatially and temporally bound on the Board.
15
+ Some Cards may have Sensors attached to them, which listen for Participant behavior, and emits an Action when triggered.
16
+ """
17
+ # Identifiers
18
+ card_id: CardId = pydantic.Field(default_factory=uuid4)
19
+ card_type: str
20
+ card_parameters: Any
21
+ # Spatial
22
+ card_shape: BoardRectangle
23
+ card_location: BoardLocation
24
+ # Temporal
25
+ card_timespan: Timespan
@@ -0,0 +1,60 @@
1
+ from typing import Literal, Annotated, Union, List
2
+
3
+ import pydantic
4
+
5
+ from nodekit._internal.models.node_engine.base import NullParameters, DslModel
6
+ from nodekit._internal.models.node_engine.cards.base import BaseCard
7
+ from nodekit._internal.models.node_engine.fields import TextContent, ColorHexString
8
+ from nodekit._internal.models.assets.base import ImageLink
9
+
10
+
11
+ # %% Concrete card classes
12
+ class FixationPointCard(BaseCard):
13
+ card_type: Literal['FixationPointCard'] = 'FixationPointCard'
14
+ card_parameters: NullParameters = pydantic.Field(default_factory=NullParameters, frozen=True)
15
+
16
+
17
+ # %%
18
+ class MarkdownPagesCard(BaseCard):
19
+ class Parameters(DslModel):
20
+ pages: List[TextContent] = pydantic.Field(
21
+ description='A list of MarkdownContent objects representing the text content on the pages to be displayed.'
22
+ )
23
+
24
+ card_type: Literal['MarkdownPagesCard'] = 'MarkdownPagesCard'
25
+ card_parameters: Parameters
26
+
27
+
28
+ # %%
29
+ class ImageCard(BaseCard):
30
+ class Parameters(DslModel):
31
+ image_link: ImageLink
32
+
33
+ card_type: Literal['ImageCard'] = 'ImageCard'
34
+ card_parameters: Parameters
35
+
36
+
37
+ # %%
38
+ class TextCard(BaseCard):
39
+ class Parameters(DslModel):
40
+ content: TextContent
41
+ background_color: ColorHexString = pydantic.Field(
42
+ default='#E6E6E6',
43
+ description='The background color of the TextCard in hexadecimal format.'
44
+ )
45
+
46
+ card_type: Literal['TextCard'] = 'TextCard'
47
+ card_parameters: Parameters
48
+
49
+
50
+ # %%
51
+ Card = Annotated[
52
+ Union[
53
+ FixationPointCard,
54
+ ImageCard,
55
+ TextCard,
56
+ MarkdownPagesCard,
57
+ # Add other Card types here as needed
58
+ ],
59
+ pydantic.Field(discriminator='card_type')
60
+ ]
@@ -0,0 +1,30 @@
1
+ from nodekit._internal.models.node_engine.fields import Timespan
2
+ from nodekit._internal.models.node_engine.base import NullParameters
3
+ from abc import ABC
4
+ import pydantic
5
+ from typing import TypeVar, Literal, Any, Annotated, Union
6
+
7
+ T = TypeVar('T', bound=str)
8
+ P = TypeVar('P', bound=pydantic.BaseModel)
9
+
10
+
11
+ class BaseEffect(pydantic.BaseModel, ABC):
12
+ effect_type: T
13
+ effect_parameters: Any
14
+ effect_timespan: Timespan = pydantic.Field(default_factory=lambda: Timespan(start_time_msec=0, end_time_msec=None))
15
+
16
+
17
+ class HidePointerEffect(BaseEffect):
18
+ """
19
+ Effect to hide the pointer during a timespan.
20
+ """
21
+ effect_type: Literal['HidePointerEffect'] = 'HidePointerEffect'
22
+ effect_parameters: NullParameters = pydantic.Field(default_factory=NullParameters)
23
+
24
+
25
+ Effect = Annotated[
26
+ Union[HidePointerEffect],
27
+ pydantic.Field(
28
+ discriminator='effect_type',
29
+ )
30
+ ]
@@ -0,0 +1,85 @@
1
+ from typing import Literal, Annotated, TypeAlias
2
+ from uuid import UUID
3
+
4
+ import pydantic
5
+
6
+ SpatialSize = Annotated[float, pydantic.Field(
7
+ strict=True, ge=0, le=1, 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.'
8
+ )]
9
+ SpatialPoint = Annotated[float, pydantic.Field(strict=True, ge=-0.5, le=0.5)]
10
+ TimeDurationMsec = Annotated[int, pydantic.Field(strict=True, ge=0, description='A duration of time in milliseconds.')]
11
+ TimePointMsec = Annotated[int, pydantic.Field(strict=True, ge=0, description='A point in time relative to some start time in milliseconds.')]
12
+ CurrencyCode = Literal['USD']
13
+ MarkdownString = str
14
+
15
+ CardId = UUID
16
+ SensorId = UUID
17
+ NodeId = UUID
18
+
19
+ # Alpha-supporting hex RGB color string:
20
+ ColorHexString = Annotated[
21
+ str,
22
+ pydantic.Field(
23
+ pattern=r'^#(?:[0-9a-fA-F]{3}){1,2}$',
24
+ min_length=7,
25
+ max_length=9,
26
+ )
27
+ ]
28
+
29
+
30
+ class TextContent(pydantic.BaseModel):
31
+ text: MarkdownString
32
+ text_color: ColorHexString = '#000000'
33
+ font_size: SpatialSize = 0.0175
34
+ justification_horizontal: Literal['left', 'center', 'right'] = 'left'
35
+ justification_vertical: Literal['top', 'center', 'bottom'] = 'top'
36
+
37
+
38
+ class BoardRectangle(pydantic.BaseModel):
39
+ """
40
+ Describes a rectangle on the Board, in Board units.
41
+ """
42
+ width: SpatialSize
43
+ height: SpatialSize
44
+
45
+
46
+ class BoardLocation(pydantic.BaseModel):
47
+ """
48
+ Describes the location of a point on the Board. The coordinates of that location are given under a
49
+ coordinate system where:
50
+ - (0,0) is the center of the Board.
51
+ - A unit of 1 corresponds to the *smaller* extent of the Board (the full width of the Board or the full height of the Board; whichever is smaller.).
52
+ - A positive increase in the x-dimension is rightwards.
53
+ - A positive increase in the y-dimension is upwards.
54
+ """
55
+ x: SpatialPoint
56
+ y: SpatialPoint
57
+
58
+
59
+ class Timespan(pydantic.BaseModel):
60
+ start_time_msec: TimePointMsec = pydantic.Field(
61
+ description="The start time of this Timespan relative to the NodePlay start, in milliseconds."
62
+ )
63
+
64
+ end_time_msec: TimePointMsec | None = pydantic.Field(
65
+ description="The time, relative to NodePlay start, when the time span ends, in milliseconds. If None, the time span is open-ended and continues until the end of NodePlay.",
66
+ default=None,
67
+ )
68
+
69
+ def check_if_subset_of_other(self, timespan_other: 'Timespan'):
70
+ """
71
+ Check if this Timespan is a subset of another Timespan.
72
+ """
73
+ if self.start_time_msec < timespan_other.start_time_msec:
74
+ return False
75
+
76
+ if self.end_time_msec is not None and timespan_other.end_time_msec is not None:
77
+ if self.end_time_msec > timespan_other.end_time_msec:
78
+ return False
79
+ return True
80
+
81
+ def is_finite(self) -> bool:
82
+ """
83
+ Check if the Timespan is finite (i.e., has a defined end time).
84
+ """
85
+ return self.end_time_msec is not None