exp-runtime-creator 0.6.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.
@@ -0,0 +1,185 @@
1
+ """Bounded, reproducible player-input workflows over the production debugger."""
2
+ from .model import ProjectError
3
+
4
+ ENDS = {'episode_exit', 'finished'}
5
+ INPUTS = {'choice', 'text_input', 'word_game', 'word_grid', 'football', 'character_picker'}
6
+ ACKNOWLEDGE = {'dialogue', 'presentation', 'message_panel', 'survey_confirmation'}
7
+
8
+
9
+ class Playtest:
10
+ def __init__(self, debugger):
11
+ self.debugger = debugger
12
+ self.transcript = []
13
+ self.last_event = None
14
+ self.sequence = 0
15
+ self.error = None
16
+
17
+ def capture(self):
18
+ raw = self.debugger.state()
19
+ details = raw['details']
20
+ token = (raw['scene'], raw['pc'], self.debugger.session.vm.steps_executed,
21
+ raw['action'], details.get('page_start'))
22
+ if raw['action'] and token != self.last_event:
23
+ self.last_event = token
24
+ self.sequence += 1
25
+ self.transcript.append(dict(sequence=self.sequence, node_id=raw['node'], action=raw['action'],
26
+ speaker=details.get('speaker', details.get('title', '')), text=details.get('text', details.get('prompt', '')),
27
+ options=self.choices(raw), page_start=details.get('page_start')))
28
+ del self.transcript[:-200]
29
+ return raw
30
+
31
+ def choices(self, raw):
32
+ if raw['action'] != 'choice': return []
33
+ details = raw['details']
34
+ return [dict(id=f'{raw["node"]}:{value}', text=text, enabled=enabled, visible_index=index)
35
+ for index, (text, value, enabled) in enumerate(zip(details['options'], details['values'], details['enabled']))]
36
+
37
+ def state(self, *, verbose=False):
38
+ raw = self.capture()
39
+ action = raw['action']
40
+ choices = self.choices(raw)
41
+ actions = []
42
+ if action in ACKNOWLEDGE or action == 'vm_pause': actions.append('continue')
43
+ if action == 'choice': actions.append('choose')
44
+ if action == 'text_input': actions.append('answer_text')
45
+ if action in ('word_game', 'football', 'character_picker'): actions.append('answer')
46
+ if action == 'word_grid': actions.append('grid_pointer')
47
+ if action not in ENDS and action is not None: actions.append('tick')
48
+ if action is None: actions.extend(('run', 'step'))
49
+ result = dict(node_id=raw['node'], scene=raw['scene'], pc=raw['pc'], action=action,
50
+ paused=self.debugger.paused, variables=raw['variables'], text_variables=raw['text_variables'],
51
+ text=raw['details'].get('text', raw['details'].get('prompt', '')),
52
+ speaker=raw['details'].get('speaker', raw['details'].get('title', '')), choices=choices,
53
+ remaining_ms=self.debugger.session.remaining_ms, next_actions=actions,
54
+ visited_count=len(self.debugger.visited), seed=self.debugger.seed,
55
+ manual_overrides=list(self.debugger.overrides), error=self.error)
56
+ if action not in INPUTS | ACKNOWLEDGE | ENDS | {'loading', None}:
57
+ result.update(pending_arguments=raw['pending_arguments'], details=raw['details'])
58
+ if verbose: result['vm'] = raw
59
+ return result
60
+
61
+ def perform(self, operation):
62
+ before = self.debugger.state()['variables']
63
+ sequence = self.sequence
64
+ self.error = None
65
+ try:
66
+ reason = operation()
67
+ except (ValueError, KeyError, IndexError) as error:
68
+ # A real VM/service failure is visible and stays suspended. Do not
69
+ # supply a guessed callback result or roll gameplay forward.
70
+ self.error = str(error)
71
+ reason = 'error'
72
+ result = self.state()
73
+ result.update(stop_reason=reason, variable_changes={key: dict(before=before.get(key), after=value)
74
+ for key, value in result['variables'].items() if before.get(key) != value},
75
+ transcript=[row for row in self.transcript if row['sequence'] > sequence],
76
+ transcript_truncated=self.sequence - sequence > 200)
77
+ return result
78
+
79
+ def run(self, *, until='input', node_id=None, max_operations=500, max_ms=60000):
80
+ if until not in ('input', 'choice', 'end', 'node'): raise ProjectError('Use until input, choice, end or node')
81
+ if until == 'node' and node_id not in {n.id for n in self.debugger.project.nodes}:
82
+ raise ProjectError('Select an existing node as the stopping point')
83
+ if type(max_operations) is not int or not 1 <= max_operations <= 2000:
84
+ raise ProjectError('Operation budget must be 1–2000')
85
+ if type(max_ms) is not int or not 0 <= max_ms <= 60000:
86
+ raise ProjectError('Clock budget must be 0–60000 ms')
87
+ def execute():
88
+ elapsed = 0
89
+ original = set(self.debugger.breakpoints)
90
+ if until == 'node': self.debugger.breakpoints.add(node_id)
91
+ try:
92
+ for _ in range(max_operations):
93
+ raw = self.capture(); action = raw['action']
94
+ if action in ENDS: return 'end'
95
+ if self.debugger.paused and (raw['node'] == node_id or raw['node'] in original): return 'breakpoint'
96
+ if action in INPUTS: return 'choice' if action == 'choice' else 'input'
97
+ if until == 'input' and action in ACKNOWLEDGE: return 'input'
98
+ if action in ACKNOWLEDGE:
99
+ session = self.debugger.session
100
+ ready = (session.engine.dialogue_animation.complete if action == 'dialogue' else
101
+ session.engine.title_screen.ready if action == 'presentation' else
102
+ session.engine.message_panel.ready)
103
+ if ready:
104
+ self.debugger.resume(provide_answer=True)
105
+ else:
106
+ if elapsed >= max_ms: return 'clock_budget'
107
+ step = min(100, max_ms - elapsed)
108
+ self.debugger.resume(milliseconds=step); elapsed += step
109
+ elif action == 'loading':
110
+ if elapsed >= max_ms: return 'clock_budget'
111
+ step = min(100, max_ms - elapsed)
112
+ self.debugger.resume(milliseconds=step); elapsed += step
113
+ elif action is None:
114
+ self.debugger.resume()
115
+ else:
116
+ return 'unsupported' # Includes explicit VM pauses; only manual input may resume those.
117
+ return 'operation_budget'
118
+ finally:
119
+ self.debugger.breakpoints = original
120
+ return self.perform(execute)
121
+
122
+ def choose(self, option_id):
123
+ raw = self.debugger.state()
124
+ option = next((o for o in self.choices(raw) if o['id'] == option_id), None)
125
+ if option is None or not option['enabled']: raise ProjectError('Choose an enabled option ID from the current state')
126
+ return self.perform(lambda: self.debugger.resume(answer=option['visible_index'], provide_answer=True) and 'input')
127
+
128
+ def input(self, action, *, value=None, milliseconds=0, phase=None, cell=None):
129
+ if action not in ('continue', 'answer_text', 'answer', 'tick', 'step', 'grid_pointer'):
130
+ raise ProjectError('Unknown playtest input')
131
+ if type(milliseconds) is not int or not 0 <= milliseconds <= 60000:
132
+ raise ProjectError('Clock step must be 0–60000 ms')
133
+ def execute():
134
+ if action == 'tick':
135
+ if milliseconds: self.debugger.resume(milliseconds=milliseconds)
136
+ elif action == 'step': self.debugger.resume(step=True)
137
+ elif action == 'grid_pointer':
138
+ from .debugger import _Paused
139
+ if phase not in ('down', 'move', 'up', 'cancel'): raise ProjectError('Invalid pointer phase')
140
+ try: self.debugger.session.grid_pointer(phase, cell)
141
+ except _Paused: pass
142
+ else:
143
+ self.debugger.resume(answer=None if action == 'continue' else value, provide_answer=True)
144
+ return 'input'
145
+ return self.perform(execute)
146
+
147
+
148
+ def run_scenario(debugger, scenario):
149
+ """Replay a declarative scenario. No eval, arbitrary code or fabricated results."""
150
+ steps = scenario.get('steps', [])
151
+ assertions = scenario.get('assertions', [])
152
+ if (not isinstance(steps, list) or len(steps) > 200 or not isinstance(assertions, list) or
153
+ len(assertions) > 200 or set(scenario) - {'seed', 'start', 'language', 'steps', 'assertions'}):
154
+ raise ProjectError('A scenario allows seed/start/language, up to 200 steps and 200 assertions')
155
+ test, results, failures = Playtest(debugger), [], []
156
+ for index, step in enumerate(steps):
157
+ try:
158
+ args = dict(step); action = args.pop('action')
159
+ if action == 'run': result = test.run(**args)
160
+ elif action == 'choose': result = test.choose(**args)
161
+ else: result = test.input(action, **args)
162
+ results.append(dict(step=index, action=result['action'], node_id=result['node_id'],
163
+ stop_reason=result['stop_reason'], variable_changes=result['variable_changes']))
164
+ if result['stop_reason'] in ('error', 'unsupported', 'operation_budget', 'clock_budget'):
165
+ failures.append(dict(step=index, message=result['error'] or result['stop_reason'])); break
166
+ except (TypeError, ValueError, KeyError) as error:
167
+ failures.append(dict(step=index, message=str(error))); break
168
+ state = test.state()
169
+ for index, assertion in enumerate(assertions):
170
+ kind = assertion.get('kind') if isinstance(assertion, dict) else None
171
+ if kind in ('variable', 'text_variable'):
172
+ values = state['variables' if kind == 'variable' else 'text_variables']
173
+ if assertion.get('name') not in values or 'equals' not in assertion:
174
+ failures.append(dict(assertion=index, message='Name must identify an existing variable and equals is required')); continue
175
+ actual, expected = values[assertion['name']], assertion['equals']
176
+ elif kind == 'node': actual, expected = state['node_id'], assertion.get('equals')
177
+ elif kind == 'action': actual, expected = state['action'], assertion.get('equals')
178
+ elif kind == 'visited': actual, expected = assertion.get('node_id') in debugger.visited, True
179
+ elif kind == 'ended': actual, expected = state['action'] in ENDS, True
180
+ else:
181
+ failures.append(dict(assertion=index, message='Unknown assertion kind')); continue
182
+ if actual != expected:
183
+ failures.append(dict(assertion=index, message='Assertion failed', actual=actual, expected=expected))
184
+ return dict(passed=not failures, failures=failures, steps=results, state=state, transcript=test.transcript,
185
+ coverage=dict(visited=sorted(debugger.visited), total=len(debugger.project.nodes)))
@@ -0,0 +1,30 @@
1
+ """Run an exported episode with the production player and isolated saves."""
2
+ from contextlib import contextmanager
3
+ from copy import copy
4
+ from pathlib import Path
5
+ import tempfile
6
+
7
+ from exp_runtime.content import ContentLibrary, EpisodeResources, digest
8
+ from exp_runtime.exp import ExpArchive
9
+ from .model import ProjectError
10
+
11
+
12
+ @contextmanager
13
+ def preview_resources(data, library_path, game):
14
+ archive = ExpArchive(data)
15
+ meta = archive.metadata()
16
+ with ContentLibrary(Path(library_path)) as library:
17
+ if library.game_id != game:
18
+ raise ProjectError('Preview library does not match the project game')
19
+ with tempfile.TemporaryDirectory(prefix='exp-creator-preview-') as temporary:
20
+ isolated = copy(library)
21
+ isolated.directory = Path(temporary)
22
+ record = dict(id=digest(data), sha256=digest(data), name=meta.title + '.exp',
23
+ titles=list(meta.titles), pack_id=meta.pack_id, episode_id=meta.episode_id)
24
+ yield EpisodeResources(isolated, record, archive)
25
+
26
+
27
+ def play_preview(path, library_path, game):
28
+ from exp_runtime.desktop import play
29
+ with preview_resources(Path(path).read_bytes(), library_path, game) as resources:
30
+ play(resources)
@@ -0,0 +1,102 @@
1
+ """Small, discoverable authoring contracts and authored examples."""
2
+ from copy import deepcopy
3
+ from dataclasses import asdict
4
+
5
+ from .features import DEFAULTS, NAMES
6
+ from .model import Node, ProjectError
7
+
8
+
9
+ FIELDS = {
10
+ 'dialogue': ('text', 'speaker', 'portrait', 'theme', 'character', 'expression', 'next'),
11
+ 'choice': ('text', 'speaker', 'theme', 'character', 'options', 'timeout_ms', 'timeout_option'),
12
+ 'background': ('asset', 'next'), 'set': ('variable', 'value', 'next'),
13
+ 'branch': ('variable', 'operator', 'value', 'next', 'otherwise'),
14
+ 'text_branch': ('next', 'otherwise'), 'relationship': ('character', 'next'),
15
+ 'input': ('text', 'next'), 'title': ('text', 'next'), 'message': ('text', 'next'),
16
+ 'notice': ('text', 'next'), 'scene_label': ('text', 'next'),
17
+ 'word_game': ('text', 'character', 'next'), 'grid': ('next',), 'football': ('next',),
18
+ 'end': (), 'native': (),
19
+ }
20
+ NOTES = {
21
+ 'dialogue': 'Use character for a reusable speaker, or speaker/portrait for an inline speaker. Text must be nonempty.',
22
+ 'choice': 'Provide 2–8 nonempty answers. Conditions enable answers; timeout_option is a zero-based visible row; native shuffling moves labels/return values, while enabled flags remain on rows.',
23
+ 'background': 'asset must reference an imported image in this project.',
24
+ 'set': 'variable is required; a nonempty params.expression overrides value.',
25
+ 'branch': 'params.expression overrides variable/operator/value. Both destinations are required.',
26
+ 'relationship': 'character is required. Changes the native relationship property, with the real emotion animation.',
27
+ 'score': 'Choose the game/script property owner and key explicitly. There is no universal score property; owner -30000 is reserved.',
28
+ 'input': 'Stores a text variable. Native input uses the selected game\'s character and width limits.',
29
+ 'random': 'bound must be positive. Stores an actual native random draw in result.',
30
+ 'music': 'Music IDs are 8201–8232 in SHS and 8201–8209 in CoD. Some SHS IDs select offsets in another audio asset.',
31
+ 'word_game': 'good needs at least 2 words; bad at least 3. Duration 1–32767 ms. Words cannot contain pipes.',
32
+ 'football': 'Six interleaved offense/defense tables, each 1–10 rows of [code, yards, variation, weight]. See defaults for a valid example.',
33
+ 'grid': 'Problems define dimensions, targets and alphabet. Tutorials use a fixed board. Native limits and table checks apply.',
34
+ 'end': 'Uses native episode-exit service 7. The player returns to its existing menu, where another installed episode can be selected.',
35
+ 'native': 'Imported native projects only. Preserve script/PC, instruction counts and data-bank lengths; no high-level reconstruction is assumed.',
36
+ }
37
+
38
+
39
+ def value_schema(value):
40
+ if value is None: return {'type': ['integer', 'null'], 'minimum': 0, 'maximum': 32767}
41
+ kind = {str: 'string', int: 'integer', float: 'number', bool: 'boolean', dict: 'object', list: 'array'}[type(value)]
42
+ result = dict(type=kind, default=deepcopy(value))
43
+ if kind == 'integer': result.update(minimum=-32768, maximum=32767)
44
+ if kind == 'array' and value: result['items'] = value_schema(value[0])
45
+ return result
46
+
47
+
48
+ def node_schema(kind=None):
49
+ if kind is None:
50
+ return dict(kinds=[dict(kind=k, title=v, games=['shs'] if k in ('football', 'grid') else ['shs', 'cod'])
51
+ for k, v in NAMES.items()],
52
+ languages=['en', 'fr', 'it', 'de', 'es'],
53
+ expressions='Integers, variables, + - * // %, comparisons, and/or/not; signed 16-bit arithmetic, no calls.',
54
+ workflow='project_get → graph_search_nodes/graph_get_subgraph → project_preview_edits → project_apply_edits → project_validate → playtest → project_export_exp',
55
+ compatibility='Native KiWi/CSPUD export; full original-app playback remains unverified. Consult each kind for restrictions.',
56
+ resources=['creator://schema/{kind}', 'creator://examples/branching'],
57
+ limits=dict(nodes=20000, characters=199, options=8, batch_operations=200))
58
+ if kind not in NAMES: raise ProjectError('Unknown node kind')
59
+ defaults = asdict(Node('example', kind=kind))
60
+ names = ('id', 'kind', 'label', 'x', 'y', 'scene', *FIELDS.get(kind, ('next',)))
61
+ properties = {name: value_schema(defaults[name]) for name in names}
62
+ properties['id'].update(minLength=1, maxLength=200, description='Stable, unique node ID; use readable names such as school_arrival.')
63
+ properties['kind'] = {'const': kind}
64
+ properties['scene'].update(minimum=25001, maximum=25999)
65
+ properties['x'] = properties['y'] = dict(type='number', minimum=-100000, maximum=100000, default=0)
66
+ for name in ('theme', 'expression'):
67
+ if name in properties: properties[name].update(minimum=0, maximum=3 if name == 'theme' else 4)
68
+ if 'options' in properties:
69
+ properties['options'].update(minItems=2, maxItems=8, items=dict(type='object', additionalProperties=False,
70
+ properties=dict(text=dict(type='string', minLength=1), target=dict(type='string', minLength=1),
71
+ condition=dict(type='string', default='')), required=['text', 'target']))
72
+ properties['timeout_ms'].update(minimum=0, description='0 disables the timer; otherwise milliseconds, maximum 32767.')
73
+ properties['timeout_option'].update(minimum=0, maximum=7)
74
+ properties['params'] = dict(type='object', additionalProperties=False,
75
+ properties={key: value_schema(value) for key, value in DEFAULTS[kind].items()}, default=deepcopy(DEFAULTS[kind]))
76
+ if kind == 'native':
77
+ properties['params'] = dict(type='object', description='Use native_get_data and graph_get_subgraph to inspect the imported block before editing.')
78
+ example = dict(id='example', kind=kind, params=deepcopy(DEFAULTS[kind]))
79
+ if 'next' in properties: example['next'] = 'end'
80
+ if 'otherwise' in properties: example['otherwise'] = 'alternate_end'
81
+ if 'text' in properties: example['text'] = 'An authored example.'
82
+ if kind in ('set', 'branch'): example['variable'] = 'points'
83
+ if kind == 'choice':
84
+ example['options'] = [dict(text='Help', target='help'), dict(text='Leave', target='end')]
85
+ if kind == 'relationship': example['character'] = 'friend'
86
+ if kind == 'background': example['asset'] = 26000
87
+ return dict(kind=kind, title=NAMES[kind], games=['shs'] if kind in ('football', 'grid') else ['shs', 'cod'],
88
+ schema=dict(type='object', properties=properties, required=['id', 'kind'], additionalProperties=False),
89
+ notes=NOTES.get(kind, 'Uses the shared native compiler and host-service contract.'), example=example,
90
+ compatibility='native_format',
91
+ original_app_playback='unverified')
92
+
93
+
94
+ def branching_example():
95
+ return dict(description='Apply to a new default project; uses no imported game assets.', operations=[
96
+ dict(op='node', id='start', changes=dict(text='A friend needs help.', next='decision')),
97
+ dict(op='node', id='decision', changes=dict(kind='choice', text='What do you do?', options=[
98
+ dict(text='Help', target='reward'), dict(text='Leave', target='end')])),
99
+ dict(op='node', id='reward', changes=dict(kind='set', variable='points', value=1, next='end'))],
100
+ scenario=dict(seed=7, steps=[dict(action='run', until='choice'), dict(action='choose', option_id='decision:0'),
101
+ dict(action='run', until='end')],
102
+ assertions=[dict(kind='variable', name='points', equals=1), dict(kind='ended')]))
@@ -0,0 +1,169 @@
1
+ """Local desktop discovery, bounded JSON transport and cooperative file locks.
2
+
3
+ The desktop owns its live document. MCP clients read snapshots and submit a
4
+ revision-checked replacement; an unavailable desktop never falls back to disk.
5
+ This module has no Qt or MCP dependency.
6
+ """
7
+ from dataclasses import dataclass
8
+ import hashlib
9
+ import json
10
+ import os
11
+ from pathlib import Path
12
+ import secrets
13
+ import socket
14
+ import struct
15
+
16
+ from .model import (MAX_PROJECT_BYTES, Project, ProjectError, atomic_write, project_bytes,
17
+ project_from_bytes, project_from_record)
18
+
19
+ MAX_MESSAGE = MAX_PROJECT_BYTES * 2
20
+ TIMEOUT = 15
21
+
22
+
23
+ class SharingError(ProjectError):
24
+ code = 'desktop_unavailable'
25
+
26
+
27
+ class ProjectBusy(SharingError):
28
+ code = 'project_busy'
29
+
30
+
31
+ def digest(data):
32
+ return hashlib.sha256(data).hexdigest()
33
+
34
+
35
+ def state_path(path, suffix):
36
+ path = Path(path).resolve()
37
+ folder = path.parent / '.creator-session'
38
+ if folder.is_symlink(): raise SharingError('The creator session directory must not be a symlink')
39
+ return folder / (digest(path.name.encode()) + suffix)
40
+
41
+
42
+ class FileLock:
43
+ """Nonblocking process lock; the OS releases it if the owner crashes."""
44
+ def __init__(self, path):
45
+ self.path, self.stream = Path(path), None
46
+
47
+ def __enter__(self):
48
+ self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
49
+ if self.path.is_symlink(): raise SharingError('Creator lock files must not be symlinks')
50
+ self.stream = open(self.path, 'a+b')
51
+ if not self.stream.seek(0, 2):
52
+ self.stream.write(b'\0'); self.stream.flush()
53
+ self.stream.seek(0)
54
+ try:
55
+ if os.name == 'nt':
56
+ import msvcrt
57
+ msvcrt.locking(self.stream.fileno(), msvcrt.LK_NBLCK, 1)
58
+ else:
59
+ import fcntl
60
+ fcntl.flock(self.stream.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
61
+ except OSError as error:
62
+ self.stream.close(); self.stream = None
63
+ raise ProjectBusy('Project is busy in another operation or desktop window; retry after it finishes.') from error
64
+ return self
65
+
66
+ def __exit__(self, *args):
67
+ if self.stream is not None:
68
+ self.stream.close()
69
+ self.stream = None
70
+
71
+
72
+ def guard(path):
73
+ if not Path(path).resolve().parent.is_dir(): raise ProjectError('Project directory must already exist')
74
+ return FileLock(state_path(path, '.lock'))
75
+
76
+
77
+ def session_info(path):
78
+ marker = state_path(path, '.json')
79
+ try:
80
+ if marker.is_symlink() or marker.stat().st_size > 4096:
81
+ raise ValueError('Invalid desktop session file')
82
+ record = json.loads(marker.read_bytes())
83
+ if (not isinstance(record, dict) or record.get('version') != 1
84
+ or record.get('path') != str(Path(path).resolve())
85
+ or type(record.get('port')) is not int or not 1 <= record['port'] <= 65535
86
+ or not isinstance(record.get('token'), str) or len(record['token']) != 64):
87
+ raise ValueError('Invalid desktop session record')
88
+ return record
89
+ except FileNotFoundError:
90
+ return None
91
+ except (OSError, ValueError) as error:
92
+ raise SharingError('Cannot read the desktop session. Reopen the project in the creator.') from error
93
+
94
+
95
+ class Lease:
96
+ """One desktop window may own a saved project at a time."""
97
+ def __init__(self, path, port):
98
+ self.path = Path(path).resolve()
99
+ self.owner = FileLock(state_path(self.path, '.owner'))
100
+ self.record = dict(version=1, path=str(self.path), port=port, token=secrets.token_hex(32))
101
+ with guard(self.path):
102
+ self.owner.__enter__()
103
+ try:
104
+ atomic_write(state_path(self.path, '.json'), json.dumps(self.record).encode())
105
+ except BaseException:
106
+ self.owner.__exit__()
107
+ raise
108
+
109
+ def close(self):
110
+ if self.owner.stream is not None:
111
+ # The owner lock prevents another window replacing this record.
112
+ try: state_path(self.path, '.json').unlink(missing_ok=True)
113
+ finally: self.owner.__exit__()
114
+
115
+
116
+ def encode_message(record):
117
+ data = json.dumps(record, ensure_ascii=False, allow_nan=False).encode()
118
+ if len(data) > MAX_MESSAGE: raise ProjectError('Desktop request exceeds the project size limit')
119
+ return struct.pack('>I', len(data)) + data
120
+
121
+
122
+ def _receive(stream, size):
123
+ data = bytearray()
124
+ while len(data) < size:
125
+ part = stream.recv(min(size - len(data), 1024 * 1024))
126
+ if not part: raise OSError('Desktop connection closed')
127
+ data.extend(part)
128
+ return bytes(data)
129
+
130
+
131
+ def request(info, action, **values):
132
+ try:
133
+ with socket.create_connection(('127.0.0.1', info['port']), timeout=TIMEOUT) as stream:
134
+ stream.sendall(encode_message(dict(token=info['token'], path=info['path'], action=action, **values)))
135
+ size, = struct.unpack('>I', _receive(stream, 4))
136
+ if not 0 < size <= MAX_MESSAGE: raise ValueError('Invalid desktop response size')
137
+ response = json.loads(_receive(stream, size))
138
+ if not isinstance(response, dict): raise ValueError('Invalid desktop response')
139
+ except (OSError, ValueError) as error:
140
+ raise SharingError('Desktop session is unavailable; reopen the project in the creator, then reread it '
141
+ 'before retrying. No fallback file write was attempted.') from error
142
+ if 'error' in response:
143
+ kind = ProjectBusy if response.get('code') == 'project_busy' else ProjectError
144
+ raise kind(response['error'])
145
+ return response
146
+
147
+
148
+ @dataclass
149
+ class Snapshot:
150
+ project: Project
151
+ revision: str
152
+ data: bytes
153
+ live: bool = False
154
+ unsaved: bool = False
155
+
156
+
157
+ def snapshot(path):
158
+ path = Path(path).resolve()
159
+ with guard(path):
160
+ info = session_info(path)
161
+ if info is None:
162
+ if path.stat().st_size > MAX_PROJECT_BYTES: raise ProjectError('Project file exceeds 96 MiB')
163
+ data = path.read_bytes()
164
+ return Snapshot(project_from_bytes(data, path), digest(data), data)
165
+ response = request(info, 'read')
166
+ project = project_from_record(response['project'])
167
+ data = project_bytes(project, path)
168
+ if digest(data) != response['revision']: raise SharingError('Desktop snapshot checksum does not match')
169
+ return Snapshot(project, response['revision'], data, True, response['unsaved'])