@pyscript/core 0.1.19 → 0.1.20

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 (41) hide show
  1. package/README.md +19 -2
  2. package/dist/core.js +3 -3
  3. package/dist/core.js.map +1 -1
  4. package/docs/README.md +3 -12
  5. package/package.json +3 -3
  6. package/src/config.js +32 -24
  7. package/src/core.js +1 -1
  8. package/src/stdlib/pyscript/__init__.py +34 -0
  9. package/src/stdlib/pyscript/display.py +154 -0
  10. package/src/stdlib/pyscript/event_handling.py +45 -0
  11. package/src/stdlib/pyscript/magic_js.py +32 -0
  12. package/src/stdlib/pyscript/util.py +22 -0
  13. package/src/stdlib/pyscript.js +9 -5
  14. package/src/stdlib/pyweb/pydom.py +314 -0
  15. package/tests/integration/__init__.py +0 -0
  16. package/tests/integration/conftest.py +184 -0
  17. package/tests/integration/support.py +1038 -0
  18. package/tests/integration/test_00_support.py +495 -0
  19. package/tests/integration/test_01_basic.py +353 -0
  20. package/tests/integration/test_02_display.py +452 -0
  21. package/tests/integration/test_03_element.py +303 -0
  22. package/tests/integration/test_assets/line_plot.png +0 -0
  23. package/tests/integration/test_assets/tripcolor.png +0 -0
  24. package/tests/integration/test_async.py +197 -0
  25. package/tests/integration/test_event_handling.py +193 -0
  26. package/tests/integration/test_importmap.py +66 -0
  27. package/tests/integration/test_interpreter.py +98 -0
  28. package/tests/integration/test_plugins.py +419 -0
  29. package/tests/integration/test_py_config.py +294 -0
  30. package/tests/integration/test_py_repl.py +663 -0
  31. package/tests/integration/test_py_terminal.py +270 -0
  32. package/tests/integration/test_runtime_attributes.py +64 -0
  33. package/tests/integration/test_script_type.py +121 -0
  34. package/tests/integration/test_shadow_root.py +33 -0
  35. package/tests/integration/test_splashscreen.py +124 -0
  36. package/tests/integration/test_stdio_handling.py +370 -0
  37. package/tests/integration/test_style.py +47 -0
  38. package/tests/integration/test_warnings_and_banners.py +32 -0
  39. package/tests/integration/test_zz_examples.py +419 -0
  40. package/tests/integration/test_zzz_docs_snippets.py +305 -0
  41. package/types/stdlib/pyscript.d.ts +8 -4
@@ -0,0 +1,66 @@
1
+ import pytest
2
+
3
+ from .support import PyScriptTest
4
+
5
+
6
+ @pytest.mark.xfail(reason="See PR #938")
7
+ class TestImportmap(PyScriptTest):
8
+ def test_importmap(self):
9
+ src = """
10
+ export function say_hello(who) {
11
+ console.log("hello from", who);
12
+ }
13
+ """
14
+ self.writefile("mymod.js", src)
15
+ #
16
+ self.pyscript_run(
17
+ """
18
+ <script type="importmap">
19
+ {
20
+ "imports": {
21
+ "mymod": "/mymod.js"
22
+ }
23
+ }
24
+ </script>
25
+
26
+ <script type="module">
27
+ import { say_hello } from "mymod";
28
+ say_hello("JS");
29
+ </script>
30
+
31
+ <py-script>
32
+ import mymod
33
+ mymod.say_hello("Python")
34
+ </py-script>
35
+ """
36
+ )
37
+ assert self.console.log.lines == [
38
+ "hello from JS",
39
+ "hello from Python",
40
+ ]
41
+
42
+ def test_invalid_json(self):
43
+ self.pyscript_run(
44
+ """
45
+ <script type="importmap">
46
+ this is not valid JSON
47
+ </script>
48
+
49
+ <py-script>
50
+ print("hello world")
51
+ </py-script>
52
+ """,
53
+ wait_for_pyscript=False,
54
+ )
55
+ # this error is raised by the browser itself, when *it* tries to parse
56
+ # the import map
57
+ self.check_js_errors("Failed to parse import map")
58
+
59
+ self.wait_for_pyscript()
60
+ assert self.console.log.lines == [
61
+ "hello world",
62
+ ]
63
+ # this warning is shown by pyscript, when *we* try to parse the import
64
+ # map
65
+ banner = self.page.locator(".py-warning")
66
+ assert "Failed to parse import map" in banner.inner_text()
@@ -0,0 +1,98 @@
1
+ import pytest
2
+
3
+ from .support import PyScriptTest
4
+
5
+ pytest.skip(
6
+ reason="FIXME: pyscript API changed doesn't expose pyscript to window anymore",
7
+ allow_module_level=True,
8
+ )
9
+
10
+
11
+ class TestInterpreterAccess(PyScriptTest):
12
+ """Test accessing Python objects from JS via pyscript.interpreter"""
13
+
14
+ def test_interpreter_python_access(self):
15
+ self.pyscript_run(
16
+ """
17
+ <py-script>
18
+ x = 1
19
+ def py_func():
20
+ return 2
21
+ </py-script>
22
+ """
23
+ )
24
+
25
+ self.run_js(
26
+ """
27
+ const x = await pyscript.interpreter.globals.get('x');
28
+ const py_func = await pyscript.interpreter.globals.get('py_func');
29
+ const py_func_res = await py_func();
30
+ console.log(`x is ${x}`);
31
+ console.log(`py_func() returns ${py_func_res}`);
32
+ """
33
+ )
34
+ assert self.console.log.lines[-2:] == [
35
+ "x is 1",
36
+ "py_func() returns 2",
37
+ ]
38
+
39
+ def test_interpreter_script_execution(self):
40
+ """Test running Python code from js via pyscript.interpreter"""
41
+ self.pyscript_run("")
42
+
43
+ self.run_js(
44
+ """
45
+ const interface = pyscript.interpreter._remote.interface;
46
+ await interface.runPython('print("Interpreter Ran This")');
47
+ """
48
+ )
49
+
50
+ expected_message = "Interpreter Ran This"
51
+ assert self.console.log.lines[-1] == expected_message
52
+
53
+ py_terminal = self.page.wait_for_selector("py-terminal")
54
+ assert py_terminal.text_content() == expected_message
55
+
56
+ def test_backward_compatibility_runtime_script_execution(self):
57
+ """Test running Python code from js via pyscript.runtime"""
58
+ self.pyscript_run("")
59
+
60
+ self.run_js(
61
+ """
62
+ const interface = pyscript.runtime._remote.interpreter;
63
+ await interface.runPython('print("Interpreter Ran This")');
64
+ """
65
+ )
66
+
67
+ expected_message = "Interpreter Ran This"
68
+ assert self.console.log.lines[-1] == expected_message
69
+
70
+ py_terminal = self.page.wait_for_selector("py-terminal")
71
+ assert py_terminal.text_content() == expected_message
72
+
73
+ def test_backward_compatibility_runtime_python_access(self):
74
+ """Test accessing Python objects from JS via pyscript.runtime"""
75
+ self.pyscript_run(
76
+ """
77
+ <py-script>
78
+ x = 1
79
+ def py_func():
80
+ return 2
81
+ </py-script>
82
+ """
83
+ )
84
+
85
+ self.run_js(
86
+ """
87
+ const x = await pyscript.interpreter.globals.get('x');
88
+ const py_func = await pyscript.interpreter.globals.get('py_func');
89
+ const py_func_res = await py_func();
90
+ console.log(`x is ${x}`);
91
+ console.log(`py_func() returns ${py_func_res}`);
92
+ """
93
+ )
94
+
95
+ assert self.console.log.lines[-2:] == [
96
+ "x is 1",
97
+ "py_func() returns 2",
98
+ ]
@@ -0,0 +1,419 @@
1
+ import pytest
2
+
3
+ from .support import PyScriptTest, skip_worker
4
+
5
+ pytest.skip(
6
+ reason="FIX LATER: pyscript NEXT doesn't support plugins yet",
7
+ allow_module_level=True,
8
+ )
9
+
10
+ # Source code of a simple plugin that creates a Custom Element for testing purposes
11
+ CE_PLUGIN_CODE = """
12
+ from pyscript import Plugin
13
+ from js import console
14
+
15
+ plugin = Plugin('py-upper')
16
+
17
+ console.log("py_upper Plugin loaded")
18
+
19
+ @plugin.register_custom_element('py-up')
20
+ class Upper:
21
+ def __init__(self, element):
22
+ self.element = element
23
+
24
+ def connect(self):
25
+ console.log("Upper plugin connected")
26
+ return self.element.originalInnerHTML.upper()
27
+ """
28
+
29
+ # Source of a plugin hooks into the PyScript App lifecycle events
30
+ HOOKS_PLUGIN_CODE = """
31
+ from pyscript import Plugin
32
+ from js import console
33
+
34
+ class TestLogger(Plugin):
35
+ def configure(self, config):
36
+ console.log('configure called')
37
+
38
+ def beforeLaunch(self, config):
39
+ console.log('beforeLaunch called')
40
+
41
+ def afterSetup(self, config):
42
+ console.log('afterSetup called')
43
+
44
+ def afterStartup(self, config):
45
+ console.log('afterStartup called')
46
+
47
+ def beforePyScriptExec(self, interpreter, src, pyScriptTag):
48
+ console.log(f'beforePyScriptExec called')
49
+ console.log(f'before_src:{src}')
50
+
51
+ def afterPyScriptExec(self, interpreter, src, pyScriptTag, result):
52
+ console.log(f'afterPyScriptExec called')
53
+ console.log(f'after_src:{src}')
54
+
55
+ def onUserError(self, config):
56
+ console.log('onUserError called')
57
+
58
+
59
+ plugin = TestLogger()
60
+ """
61
+
62
+ # Source of script that defines a plugin with only beforePyScriptExec and
63
+ # afterPyScriptExec methods
64
+ PYSCRIPT_HOOKS_PLUGIN_CODE = """
65
+ from pyscript import Plugin
66
+ from js import console
67
+
68
+ class ExecTestLogger(Plugin):
69
+
70
+ async def beforePyScriptExec(self, interpreter, src, pyScriptTag):
71
+ console.log(f'beforePyScriptExec called')
72
+ console.log(f'before_src:{src}')
73
+
74
+ async def afterPyScriptExec(self, interpreter, src, pyScriptTag, result):
75
+ console.log(f'afterPyScriptExec called')
76
+ console.log(f'after_src:{src}')
77
+ console.log(f'result:{result}')
78
+
79
+
80
+ plugin = ExecTestLogger()
81
+ """
82
+
83
+ # Source of script that defines a plugin with only beforePyScriptExec and
84
+ # afterPyScriptExec methods
85
+ PYREPL_HOOKS_PLUGIN_CODE = """
86
+ from pyscript import Plugin
87
+ from js import console
88
+
89
+ console.warn("This is in pyrepl hooks file")
90
+
91
+ class PyReplTestLogger(Plugin):
92
+
93
+ def beforePyReplExec(self, interpreter, src, outEl, pyReplTag):
94
+ console.log(f'beforePyReplExec called')
95
+ console.log(f'before_src:{src}')
96
+
97
+ def afterPyReplExec(self, interpreter, src, outEl, pyReplTag, result):
98
+ console.log(f'afterPyReplExec called')
99
+ console.log(f'after_src:{src}')
100
+ console.log(f'result:{result}')
101
+
102
+
103
+ plugin = PyReplTestLogger()
104
+ """
105
+
106
+ # Source of a script that doesn't call define a `plugin` attribute
107
+ NO_PLUGIN_CODE = """
108
+ from pyscript import Plugin
109
+ from js import console
110
+
111
+ class TestLogger(Plugin):
112
+ pass
113
+ """
114
+
115
+ # Source code of a simple plugin that creates a Custom Element for testing purposes
116
+ CODE_CE_PLUGIN_BAD_RETURNS = """
117
+ from pyscript import Plugin
118
+ from js import console
119
+
120
+ plugin = Plugin('py-broken')
121
+
122
+ @plugin.register_custom_element('py-up')
123
+ class Upper:
124
+ def __init__(self, element):
125
+ self.element = element
126
+
127
+ def connect(self):
128
+ # Just returning something... anything other than a string should be ignore
129
+ return Plugin
130
+ """
131
+ HTML_TEMPLATE_WITH_TAG = """
132
+ <py-config>
133
+ plugins = [
134
+ "./{plugin_name}.py"
135
+ ]
136
+ </py-config>
137
+
138
+ <{tagname}>
139
+ {html}
140
+ </{tagname}>
141
+ """
142
+ HTML_TEMPLATE_NO_TAG = """
143
+ <py-config>
144
+ plugins = [
145
+ "./{plugin_name}.py"
146
+ ]
147
+ </py-config>
148
+ """
149
+
150
+
151
+ def prepare_test(
152
+ plugin_name, code, tagname="", html="", template=HTML_TEMPLATE_WITH_TAG
153
+ ):
154
+ """
155
+ Prepares the test by writing a new plugin file named `plugin_name`.py, with `code` as its
156
+ content and run `pyscript_run` on `template` formatted with the above inputs to create the
157
+ page HTML code.
158
+
159
+ For example:
160
+
161
+ >> @prepare_test('py-upper', CE_PLUGIN_CODE, tagname='py-up', html="Hello World")
162
+ >> def my_foo(...):
163
+ >> ...
164
+
165
+ will:
166
+
167
+ * write a new `py-upper.py` file to the FS
168
+ * the contents of `py-upper.py` is equal to CE_PLUGIN_CODE
169
+ * call self.pyscript_run with the following string:
170
+ '''
171
+ <py-config>
172
+ plugins = [
173
+ "./py-upper.py"
174
+ ]
175
+ </py-config>
176
+
177
+ <py-up>
178
+ {html}
179
+ </py-up>
180
+ '''
181
+ * call `my_foo` just like a normal decorator would
182
+
183
+ """
184
+
185
+ def dec(f):
186
+ def _inner(self, *args, **kws):
187
+ self.writefile(f"{plugin_name}.py", code)
188
+ page_html = template.format(
189
+ plugin_name=plugin_name, tagname=tagname, html=html
190
+ )
191
+ self.pyscript_run(page_html)
192
+ return f(self, *args, **kws)
193
+
194
+ return _inner
195
+
196
+ return dec
197
+
198
+
199
+ class TestPlugin(PyScriptTest):
200
+ @skip_worker("FIXME: relative paths")
201
+ @prepare_test("py-upper", CE_PLUGIN_CODE, tagname="py-up", html="Hello World")
202
+ def test_py_plugin_inline(self):
203
+ """Test that a regular plugin that returns new HTML content from connected works"""
204
+ # GIVEN a plugin that returns the all caps version of the tag innerHTML and logs text
205
+ # during it's execution/hooks
206
+
207
+ # EXPECT the plugin logs to be present in the console logs
208
+ log_lines = self.console.log.lines
209
+ for log_line in ["py_upper Plugin loaded", "Upper plugin connected"]:
210
+ assert log_line in log_lines
211
+
212
+ # EXPECT the inner text of the Plugin CustomElement to be all caps
213
+ rendered_text = self.page.locator("py-up").inner_text()
214
+ assert rendered_text == "HELLO WORLD"
215
+
216
+ @skip_worker("FIXME: relative paths")
217
+ @prepare_test("hooks_logger", HOOKS_PLUGIN_CODE, template=HTML_TEMPLATE_NO_TAG)
218
+ def test_execution_hooks(self):
219
+ """Test that a Plugin that hooks into the PyScript App events, gets called
220
+ for each one of them"""
221
+ # GIVEN a plugin that logs specific strings for each app execution event
222
+ hooks_available = ["afterSetup", "afterStartup"]
223
+ hooks_unavailable = [
224
+ "configure",
225
+ "beforeLaunch",
226
+ "beforePyScriptExec",
227
+ "afterPyScriptExec",
228
+ "beforePyReplExec",
229
+ "afterPyReplExec",
230
+ ]
231
+
232
+ # EXPECT it to log the correct logs for the events it intercepts
233
+ log_lines = self.console.log.lines
234
+ num_calls = {
235
+ method: log_lines.count(f"{method} called") for method in hooks_available
236
+ }
237
+ expected_calls = {method: 1 for method in hooks_available}
238
+ assert num_calls == expected_calls
239
+
240
+ # EXPECT it to NOT be called (hence not log anything) the events that happen
241
+ # before it's ready, hence is not called
242
+ unavailable_called = {
243
+ method: f"{method} called" in log_lines for method in hooks_unavailable
244
+ }
245
+ assert unavailable_called == {method: False for method in hooks_unavailable}
246
+
247
+ # TODO: It'd be actually better to check that the events get called in order
248
+
249
+ @skip_worker("FIXME: relative paths")
250
+ @prepare_test(
251
+ "exec_test_logger",
252
+ PYSCRIPT_HOOKS_PLUGIN_CODE,
253
+ template=HTML_TEMPLATE_NO_TAG + "\n<py-script id='pyid'>x=2; x</py-script>",
254
+ )
255
+ def test_pyscript_exec_hooks(self):
256
+ """Test that the beforePyScriptExec and afterPyScriptExec hooks work as intended"""
257
+ assert self.page.locator("py-script") is not None
258
+
259
+ log_lines: list[str] = self.console.log.lines
260
+
261
+ assert "beforePyScriptExec called" in log_lines
262
+ assert "afterPyScriptExec called" in log_lines
263
+
264
+ # These could be made better with a utility function that found log lines
265
+ # that match a filter function, or start with something
266
+ assert "before_src:x=2; x" in log_lines
267
+ assert "after_src:x=2; x" in log_lines
268
+ assert "result:2" in log_lines
269
+
270
+ @skip_worker("FIXME: relative paths")
271
+ @prepare_test(
272
+ "pyrepl_test_logger",
273
+ PYREPL_HOOKS_PLUGIN_CODE,
274
+ template=HTML_TEMPLATE_NO_TAG + "\n<py-repl id='pyid'>x=2; x</py-repl>",
275
+ )
276
+ def test_pyrepl_exec_hooks(self):
277
+ py_repl = self.page.locator("py-repl")
278
+ py_repl.locator("button").click()
279
+ # allow afterPyReplExec to also finish before the test finishes
280
+ self.wait_for_console("result:2")
281
+
282
+ log_lines: list[str] = self.console.log.lines
283
+
284
+ assert "beforePyReplExec called" in log_lines
285
+ assert "afterPyReplExec called" in log_lines
286
+
287
+ # These could be made better with a utility function that found log lines
288
+ # that match a filter function, or start with something
289
+ assert "before_src:x=2; x" in log_lines
290
+ assert "after_src:x=2; x" in log_lines
291
+ assert "result:2" in log_lines
292
+
293
+ @skip_worker("FIXME: relative paths")
294
+ @prepare_test("no_plugin", NO_PLUGIN_CODE)
295
+ def test_no_plugin_attribute_error(self):
296
+ """
297
+ Test a plugin that do not add the `plugin` attribute to its module
298
+ """
299
+ # GIVEN a Plugin NO `plugin` attribute in it's module
300
+ error_msg = (
301
+ "[pyscript/main] Cannot find plugin on Python module no_plugin! Python plugins "
302
+ 'modules must contain a "plugin" attribute. For more information check the '
303
+ "plugins documentation."
304
+ )
305
+ # EXPECT an error for the missing attribute
306
+ assert error_msg in self.console.error.lines
307
+
308
+ @skip_worker("FIXME: relative paths")
309
+ def test_fetch_python_plugin(self):
310
+ """
311
+ Test that we can fetch a plugin from a remote URL. Note we need to use
312
+ the 'raw' URL for the plugin, otherwise the request will be rejected
313
+ by cors policy.
314
+ """
315
+ self.pyscript_run(
316
+ """
317
+ <py-config>
318
+ plugins = [
319
+ "https://raw.githubusercontent.com/FabioRosado/pyscript-plugins/main/python/hello-world.py"
320
+ ]
321
+
322
+ </py-config>
323
+ <py-hello-world></py-hello-world>
324
+ """
325
+ )
326
+
327
+ hello_element = self.page.locator("py-hello-world")
328
+ assert hello_element.inner_html() == '<div id="hello">Hello World!</div>'
329
+
330
+ def test_fetch_js_plugin(self):
331
+ self.pyscript_run(
332
+ """
333
+ <py-config>
334
+ plugins = [
335
+ "https://raw.githubusercontent.com/FabioRosado/pyscript-plugins/main/js/hello-world.js"
336
+ ]
337
+ </py-config>
338
+ """
339
+ )
340
+
341
+ hello_element = self.page.locator("py-hello-world")
342
+ assert hello_element.inner_html() == "<h1>Hello, world!</h1>"
343
+
344
+ def test_fetch_js_plugin_bare(self):
345
+ self.pyscript_run(
346
+ """
347
+ <py-config>
348
+ plugins = [
349
+ "https://raw.githubusercontent.com/FabioRosado/pyscript-plugins/main/js/hello-world-base.js"
350
+ ]
351
+ </py-config>
352
+ """
353
+ )
354
+
355
+ hello_element = self.page.locator("py-hello-world")
356
+ assert hello_element.inner_html() == "<h1>Hello, world!</h1>"
357
+
358
+ def test_fetch_plugin_no_file_extension(self):
359
+ self.pyscript_run(
360
+ """
361
+ <py-config>
362
+ plugins = [
363
+ "https://non-existent.blah/hello-world"
364
+ ]
365
+ </py-config>
366
+ """,
367
+ wait_for_pyscript=False,
368
+ )
369
+
370
+ expected_msg = (
371
+ "(PY2000): Unable to load plugin from "
372
+ "'https://non-existent.blah/hello-world'. Plugins "
373
+ "need to contain a file extension and be either a "
374
+ "python or javascript file."
375
+ )
376
+
377
+ assert self.assert_banner_message(expected_msg)
378
+
379
+ def test_fetch_js_plugin_non_existent(self):
380
+ self.pyscript_run(
381
+ """
382
+ <py-config>
383
+ plugins = [
384
+ "http://non-existent.example.com/hello-world.js"
385
+ ]
386
+ </py-config>
387
+ """,
388
+ wait_for_pyscript=False,
389
+ )
390
+
391
+ expected_msg = (
392
+ "(PY0001): Fetching from URL "
393
+ "http://non-existent.example.com/hello-world.js failed "
394
+ "with error 'Failed to fetch'. Are your filename and "
395
+ "path correct?"
396
+ )
397
+
398
+ assert self.assert_banner_message(expected_msg)
399
+
400
+ def test_fetch_js_no_export(self):
401
+ self.pyscript_run(
402
+ """
403
+ <py-config>
404
+ plugins = [
405
+ "https://raw.githubusercontent.com/FabioRosado/pyscript-plugins/main/js/hello-world-no-export.js"
406
+ ]
407
+ </py-config>
408
+ """,
409
+ wait_for_pyscript=False,
410
+ )
411
+
412
+ expected_message = (
413
+ "(PY2001): Unable to load plugin from "
414
+ "'https://raw.githubusercontent.com/FabioRosado/pyscript-plugins"
415
+ "/main/js/hello-world-no-export.js'. "
416
+ "Plugins need to contain a default export."
417
+ )
418
+
419
+ assert self.assert_banner_message(expected_message)