marimo-logilab-helper 0.1.1__tar.gz

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,36 @@
1
+ Metadata-Version: 2.3
2
+ Name: marimo-logilab-helper
3
+ Version: 0.1.1
4
+ Summary: Logilab Marimo helpers contains various functions used in Logilab training exercises implemented in Marimo notebooks
5
+ Keywords: marimo,notebook,education,logilab
6
+ Author: Logilab
7
+ Author-email: Logilab <contact@logilab.fr>
8
+ License: GNU Lesser General Public License v2 or later (LGPLv2+)
9
+ Classifier: Environment :: Web Environment
10
+ Classifier: Programming Language :: Python
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Natural Language :: English
13
+ Classifier: Natural Language :: French
14
+ Classifier: Topic :: Education
15
+ Requires-Dist: marimo>=0.14
16
+ Requires-Python: >=3.12
17
+ Project-URL: Repository, https://forge.extranet.logilab.fr/open-source/logilab-marimo-helpers
18
+ Project-URL: Issues, https://forge.extranet.logilab.fr/open-source/logilab-marimo-helpers/-/boards#/
19
+ Description-Content-Type: text/markdown
20
+
21
+ This package contains several functions that are used inside the
22
+ Marimo notebooks implementing Logilab training exercises. These
23
+ functions include:
24
+
25
+ - a function that can run a list of test functions and that displays
26
+ a table giving the result of each test function,
27
+ - several check functions that can be used inside the test functions to
28
+ verify a result,
29
+ - a function that can display, in a Markdown block, the solution stored
30
+ in a Python file,
31
+ - a function that can indicate if the Marimo notebook is running in a Web
32
+ Component (WASM) or in a Marimo regular server.
33
+ - a function that can mock the standard `open` function of Python when
34
+ the Marimo notebook is run in a Web Component (WASM); basically, instead
35
+ of opening a file on the disk (which is impossible from a program running
36
+ in the Web browser), it opens a file at an URL.
@@ -0,0 +1,16 @@
1
+ This package contains several functions that are used inside the
2
+ Marimo notebooks implementing Logilab training exercises. These
3
+ functions include:
4
+
5
+ - a function that can run a list of test functions and that displays
6
+ a table giving the result of each test function,
7
+ - several check functions that can be used inside the test functions to
8
+ verify a result,
9
+ - a function that can display, in a Markdown block, the solution stored
10
+ in a Python file,
11
+ - a function that can indicate if the Marimo notebook is running in a Web
12
+ Component (WASM) or in a Marimo regular server.
13
+ - a function that can mock the standard `open` function of Python when
14
+ the Marimo notebook is run in a Web Component (WASM); basically, instead
15
+ of opening a file on the disk (which is impossible from a program running
16
+ in the Web browser), it opens a file at an URL.
@@ -0,0 +1,57 @@
1
+ [project]
2
+ name = "marimo-logilab-helper"
3
+ version = "0.1.1"
4
+ description = "Logilab Marimo helpers contains various functions used in Logilab training exercises implemented in Marimo notebooks"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ dependencies = ["marimo>=0.14"]
8
+ keywords = [
9
+ "marimo",
10
+ "notebook",
11
+ "education",
12
+ "logilab",
13
+ ]
14
+ classifiers = [
15
+ "Environment :: Web Environment",
16
+ "Programming Language :: Python",
17
+ "Programming Language :: Python :: 3",
18
+ "Natural Language :: English",
19
+ "Natural Language :: French",
20
+ "Topic :: Education",
21
+ ]
22
+
23
+ [project.license]
24
+ text = "GNU Lesser General Public License v2 or later (LGPLv2+)"
25
+
26
+ [[project.authors]]
27
+ name = "Logilab"
28
+ email = "contact@logilab.fr"
29
+
30
+ [project.urls]
31
+ Repository = "https://forge.extranet.logilab.fr/open-source/logilab-marimo-helpers"
32
+ Issues = "https://forge.extranet.logilab.fr/open-source/logilab-marimo-helpers/-/boards#/"
33
+
34
+ [dependency-groups]
35
+ dev = [
36
+ "pytest",
37
+ "ruff",
38
+ "twine",
39
+ ]
40
+
41
+ [tool.uv.build-backend]
42
+ module-name = "mo_logilab_helpers"
43
+
44
+ [tool.ruff]
45
+ target-version = "py312"
46
+
47
+ [tool.ruff.lint]
48
+ select = [
49
+ "E4",
50
+ "E7",
51
+ "E9",
52
+ "F",
53
+ ]
54
+
55
+ [build-system]
56
+ requires = ["uv_build>=0.12.5,<0.13.0"]
57
+ build-backend = "uv_build"
@@ -0,0 +1,42 @@
1
+ [project]
2
+ name = "marimo-logilab-helper"
3
+ version = "0.1.1"
4
+ description = "Logilab Marimo helpers contains various functions used in Logilab training exercises implemented in Marimo notebooks"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ dependencies = [
8
+ "marimo>=0.14",
9
+ ]
10
+ license = {text = "GNU Lesser General Public License v2 or later (LGPLv2+)"}
11
+ authors = [
12
+ {name="Logilab", email="contact@logilab.fr"},
13
+ ]
14
+ keywords = ["marimo", "notebook", "education", "logilab"]
15
+ classifiers = [
16
+ "Environment :: Web Environment",
17
+ "Programming Language :: Python",
18
+ "Programming Language :: Python :: 3",
19
+ "Natural Language :: English",
20
+ "Natural Language :: French",
21
+ "Topic :: Education",
22
+ ]
23
+
24
+ [project.urls]
25
+ Repository = "https://forge.extranet.logilab.fr/open-source/logilab-marimo-helpers"
26
+ Issues = "https://forge.extranet.logilab.fr/open-source/logilab-marimo-helpers/-/boards#/"
27
+
28
+ [dependency-groups]
29
+ dev = ["pytest", "ruff", "twine"]
30
+
31
+ [tool.uv.build-backend]
32
+ module-name = "mo_logilab_helpers"
33
+
34
+ [tool.ruff]
35
+ target-version = "py312"
36
+
37
+ [tool.ruff.lint]
38
+ select = ["E4", "E7", "E9", "F"]
39
+
40
+ [build-system]
41
+ requires = ["uv_build>=0.12.5,<0.13.0"]
42
+ build-backend = "uv_build"
@@ -0,0 +1,47 @@
1
+ """
2
+ Package defining several useful functions that are used inside the Marimo
3
+ notebooks implementing Logilab training exercises.
4
+
5
+ These functions are:
6
+
7
+ ``run_tests``
8
+ Run a list of test functions and display their result in the Marimo cell
9
+ output.
10
+ ``check_value``
11
+ Inside a test function, checks a result is equal to a given value.
12
+ ``check_type``
13
+ Inside a test function, checks a result has the given type.
14
+ ``check_raise_exception``
15
+ Inside a test function, checks a piece of code raises an exception of a
16
+ given type
17
+
18
+ ``display_solution``
19
+ Read the code of the solution stored inside a Python file and display it
20
+ in a Markdown block inside the Marimo cell output. The solution is only
21
+ displayed when a given switch is active.
22
+ ``runs_in_web_component``
23
+ Return True if the Marimo notebook is run in a Web Component inside the
24
+ Web browser (some behaviours change in such a case and the Marimo notebook
25
+ must adapt to provide the same functionalities to the end-user).
26
+ ``mock_open_function_when_running_in_web_component``
27
+ Dynamically change the standard `open` function (that reads a file on the
28
+ disk) with a custom function that emulates this function by reading a file
29
+ through an HTTP GET request. This change is necessary when the Marimo
30
+ notebook is run in a Web Component inside the Web browser because then the
31
+ standard `open` function can't work.
32
+ """
33
+
34
+ from .tests import run_tests, check_value, check_type, check_raise_exception
35
+ from .solutions import display_solution
36
+ from .others import runs_in_web_component
37
+ from .mocks import mock_open_function_when_running_in_web_component
38
+
39
+ __all__ = [
40
+ "run_tests",
41
+ "check_value",
42
+ "check_type",
43
+ "check_raise_exception",
44
+ "display_solution",
45
+ "runs_in_web_component",
46
+ "mock_open_function_when_running_in_web_component",
47
+ ]
@@ -0,0 +1,62 @@
1
+ """
2
+ Module containing functions to mock some standard Python functions when the
3
+ Marimo notebook is run in a Web Component inside the Web browser.
4
+ """
5
+
6
+ from typing import Any
7
+ import marimo as mo
8
+ from .utils import get_message
9
+
10
+
11
+ def mock_open_function_when_running_in_web_component(
12
+ globals_dict: dict[str, Any], lang: str = "en"
13
+ ):
14
+ """
15
+ In a notebook run as a Web Component, replace the standard `open`
16
+ function with a custom function that emulates its behaviour but relies
17
+ on `urlopen` to read the data from an URL with an HTTP GET request.
18
+
19
+ This function replaces the standard `open` function in `globals()` with
20
+ our custom function. Note that the standard function is always available
21
+ in the `io` module. `globals_dict` parameter should be given `globals()`
22
+ object.
23
+
24
+ Be aware that our custom function can only emulate the reading of a file
25
+ with `open`. Writing a file can't be done through an HTTP GET request.
26
+
27
+ `lang` is only used to display a message in Marimo output.
28
+ """
29
+ from urllib.request import urlopen
30
+ from types import MethodType
31
+
32
+ # Define our mock function
33
+ def open(file, mode="r", encoding=None):
34
+ resp = urlopen(file)
35
+ # Redefine `readline` on the stream returned by urlopen because it
36
+ # doesn't decode the read text (contrary to the standard `io text
37
+ # stream).
38
+ resp._readline = resp.readline
39
+
40
+ def readline(self):
41
+ return self._readline().decode(encoding or "utf-8")
42
+
43
+ resp.readline = MethodType(readline, resp)
44
+ # Return the customised stream
45
+ return resp
46
+
47
+ # Change the standard `open` function in the globals dict.
48
+ globals_dict["__builtin__"]["open"] = open
49
+ # Display a message in the Marimo cell output.
50
+ mo.output.replace(
51
+ mo.vstack(
52
+ [
53
+ mo.icon("ant-design:warning-filled", size=50, color="red"),
54
+ mo.md(
55
+ get_message("mock_open_1", lang=lang)
56
+ + "\n\n"
57
+ + get_message("mock_open_2", lang=lang)
58
+ ).callout(kind="danger"),
59
+ ],
60
+ align="start",
61
+ )
62
+ )
@@ -0,0 +1,14 @@
1
+ """
2
+ Module containing various other useful functions
3
+ """
4
+
5
+
6
+ def runs_in_web_component() -> bool:
7
+ """
8
+ Return True if the Marimo notebook is running in a Web Component inside
9
+ the Web browser (instead of running in the Marimo server onto a regular
10
+ operating system).
11
+ """
12
+ import sys
13
+
14
+ return sys.platform == "emscripten"
@@ -0,0 +1,60 @@
1
+ """
2
+ Module defining the functions that can be used to display the solution
3
+ (content of a Python file stored near the Marimo exercice notebook.
4
+ """
5
+
6
+ from pathlib import Path
7
+ import marimo as mo
8
+ from .others import runs_in_web_component
9
+
10
+
11
+ def display_solution(
12
+ switch_obj, sol_filename: str | Path, markdown: bool = False
13
+ ):
14
+ """
15
+ Display the Python code of the solution stored inside ``sol_filename`` in
16
+ the output of the Marimo cell.
17
+
18
+ The solution is only shown when the Marimo switch ``switch_obj`` is
19
+ active.
20
+
21
+ If ``markdown`` is ``True``, the content of the solution is directly
22
+ displayed, else it is inserted in a code block marker.
23
+ """
24
+ sol_code: list[str] = []
25
+ try:
26
+ if runs_in_web_component():
27
+ # We are running inside the Web browser and must use `urlopen` to
28
+ # read the file containing the solution.
29
+ from urllib.request import urlopen
30
+
31
+ with urlopen(str(sol_filename)) as stream:
32
+ sol_code = [
33
+ line.decode("utf-8") for line in stream.readlines()
34
+ ]
35
+ else:
36
+ with open(sol_filename) as stream:
37
+ sol_code = stream.readlines()
38
+ except Exception as exc:
39
+ mode = "in file" if not runs_in_web_component() else "at URL"
40
+ sol_code = [
41
+ f"# Can't access the solution {mode}: {sol_filename}\n",
42
+ "\n",
43
+ f"# Encountered the error: {exc.__class__.__name__} {exc}\n",
44
+ ]
45
+
46
+ if switch_obj.value:
47
+ mo.output.replace(
48
+ mo.vstack(
49
+ [
50
+ switch_obj,
51
+ mo.md(
52
+ "".join(sol_code)
53
+ if markdown
54
+ else f"```python3\n{"".join(sol_code)}\n```\n"
55
+ ),
56
+ ]
57
+ )
58
+ )
59
+ else:
60
+ mo.output.replace(switch_obj)
@@ -0,0 +1,247 @@
1
+ """
2
+ Module defining the functions that can be used to run tests in the Marimo
3
+ notebook and display their results in the Marimo cell output.
4
+ """
5
+
6
+ from typing import Optional, Callable, Any, cast
7
+ import types
8
+ import numbers
9
+ from traceback import format_exc
10
+ from contextlib import contextmanager
11
+ import marimo as mo
12
+ from .utils import get_message
13
+
14
+
15
+ class CheckError(Exception):
16
+ """
17
+ Custom exception raised by the check functions (see below).
18
+ """
19
+
20
+ pass
21
+
22
+
23
+ def run_tests(
24
+ test_functions_list: list[Callable[..., None]], lang: str = "en"
25
+ ):
26
+ """
27
+ Run the functions in ``test_functions_list`` list and display their
28
+ result in a nice grid in the output of the Marimo cell. The potential
29
+ tracebacks are printed (in the printing zone of the Marimo cell).
30
+
31
+ Each function can use the check functions (also defined in this module)
32
+ to verify an expected result.
33
+ """
34
+ results_data = []
35
+ for test_function in test_functions_list:
36
+ doc_lines = [
37
+ lin.strip()
38
+ for lin in (test_function.__doc__ or "").strip().split("\n")
39
+ ]
40
+ doc_first_para = (
41
+ doc_lines[: doc_lines.index("")]
42
+ if "" in doc_lines
43
+ else doc_lines[:]
44
+ )
45
+ data_row: list[Optional[str]] = [
46
+ test_function.__name__,
47
+ " ".join(doc_first_para),
48
+ ]
49
+ results_data.append(data_row)
50
+ try:
51
+ test_function()
52
+ data_row.append(f"✅️ {get_message('ok', lang=lang)}")
53
+ data_row.append(None)
54
+ except CheckError:
55
+ data_row.append(f"⚠️ **{get_message('failed', lang=lang)}**")
56
+ data_row.append(format_exc())
57
+ except Exception:
58
+ data_row.append(f"❌️ **{get_message('error', lang=lang)}**")
59
+ data_row.append(format_exc())
60
+
61
+ output_rows = []
62
+ for name, desc, status, details in results_data:
63
+ output_rows.append(
64
+ mo.hstack(
65
+ [
66
+ mo.vstack(
67
+ [
68
+ mo.md(f"**{name}**"),
69
+ mo.md(desc),
70
+ ],
71
+ gap=0,
72
+ ),
73
+ mo.md(status),
74
+ ],
75
+ widths=[3, 1],
76
+ )
77
+ )
78
+ if details is not None:
79
+ print(f"*** {name}\n")
80
+ print(details)
81
+
82
+ mo.output.replace(mo.vstack(output_rows, heights="equal"))
83
+
84
+
85
+ def check_value(
86
+ result: Any,
87
+ expected_value: Any,
88
+ precision: int = 6,
89
+ message: Optional[str] = None,
90
+ **context: dict[str, Any],
91
+ ):
92
+ """
93
+ Check ``result`` is equal to the ``expected_value``.
94
+
95
+ If ``result`` is a number, the equality is tested with the specified
96
+ ``precision`` (difference less than 10^-<precision>). It is possible to
97
+ set ``precision`` to ``None`` to avoid this behaviour and check for a
98
+ strict equality.
99
+
100
+ ``message`` is the text written at the beginning of the error message.
101
+
102
+ Additional arguments with their values can be provided to describe the
103
+ context of the test (e.g. values used to run the function that produced
104
+ ``result``).
105
+ """
106
+ if isinstance(result, numbers.Number) and precision is not None:
107
+ power = -precision
108
+ check_test = abs(result - expected_value) < 10**power
109
+ else:
110
+ check_test = result == expected_value
111
+ if not check_test:
112
+ str_context = (
113
+ (
114
+ "\nContext: "
115
+ + " ; ".join(
116
+ f"{key}={repr(val)}" for key, val in context.items()
117
+ )
118
+ )
119
+ if context
120
+ else ""
121
+ )
122
+ msg = (
123
+ message
124
+ if message
125
+ else "The result is different from the expected value:"
126
+ )
127
+ raise CheckError(
128
+ f"{msg}\n {repr(result)}\nis different from\n"
129
+ f" {repr(expected_value)}{str_context}\n"
130
+ )
131
+
132
+
133
+ def check_type(
134
+ result: Any,
135
+ expected_type: type | tuple[type, ...] | types.UnionType,
136
+ message: Optional[str] = None,
137
+ **context: dict[str, Any],
138
+ ):
139
+ """
140
+ Check ``result`` has the ``expected_type`` type.
141
+
142
+ ``expected_type`` can be a single type, a tuple of types or an union of
143
+ types (see documentation of the standard ``isinstance`` function).
144
+
145
+ ``message`` is the text written at the beginning of the error message.
146
+
147
+ Additional arguments with their values can be provided to describe the
148
+ context of the test (e.g. values used to run the function that produced
149
+ ``result``).
150
+ """
151
+ exp_types = expected_type
152
+ if not isinstance(expected_type, (tuple, types.UnionType)):
153
+ exp_types = (expected_type,)
154
+ if not isinstance(result, exp_types):
155
+ str_types = (
156
+ str(exp_types)
157
+ if isinstance(exp_types, types.UnionType)
158
+ else " | ".join(
159
+ (f"{typ.__module__}." if typ.__module__ != "builtins" else "")
160
+ + typ.__name__
161
+ for typ in cast(tuple[type, ...], exp_types)
162
+ )
163
+ )
164
+ str_context = (
165
+ (
166
+ "\nContext: "
167
+ + " ; ".join(
168
+ f"{key}={repr(val)}" for key, val in context.items()
169
+ )
170
+ )
171
+ if context
172
+ else ""
173
+ )
174
+ msg = (
175
+ message
176
+ if message
177
+ else "The result doesn't have the expected type:"
178
+ )
179
+ raise CheckError(
180
+ f"{msg}\n {repr(result)}\nhas not a type in\n {str_types}"
181
+ f"{str_context}\n"
182
+ )
183
+
184
+
185
+ @contextmanager
186
+ def check_raise_exception(
187
+ exception_type,
188
+ message: Optional[str] = None,
189
+ **context: dict[str, Any],
190
+ ):
191
+ """
192
+ Context manager that verifies the code it contains raises an exception of
193
+ ``expected_type`` type.
194
+
195
+ ``expected_type`` can be a single type, a tuple of types or an union of
196
+ types (see documentation of the standard ``isinstance`` function).
197
+
198
+ ``message`` is the text written at the beginning of the error message.
199
+
200
+ Additional arguments with their values can be provided to describe the
201
+ context of the test (e.g. values used to run the function that raised the
202
+ exception).
203
+ """
204
+ exc_types = exception_type
205
+ if not isinstance(exception_type, (tuple, types.UnionType)):
206
+ exc_types = (exception_type,)
207
+ error = False
208
+ exc_obj: Optional[Exception] = None
209
+ try:
210
+ yield
211
+ error = True
212
+ except Exception as exc:
213
+ if not isinstance(exc, exc_types):
214
+ error = True
215
+ exc_obj = exc
216
+ finally:
217
+ if error:
218
+ str_exc_types = (
219
+ str(exc_types)
220
+ if isinstance(exc_types, types.UnionType)
221
+ else " | ".join(excp.__name__ for excp in exc_types)
222
+ )
223
+ str_context = (
224
+ (
225
+ "\nContext: "
226
+ + " ; ".join(
227
+ f"{key}={repr(val)}" for key, val in context.items()
228
+ )
229
+ )
230
+ if context
231
+ else ""
232
+ )
233
+ if exc_obj is not None:
234
+ msg = (
235
+ message
236
+ if message
237
+ else "An unexpected exception has been raised:"
238
+ )
239
+ raise CheckError(
240
+ f"{msg}\n {repr(exc_obj)}\nhas not an exception type in:"
241
+ f"\n {str_exc_types}{str_context}\n"
242
+ )
243
+ msg = message if message else "No exception has been raised:"
244
+ raise CheckError(
245
+ f"{msg}\n\nAn exception was expected with a type in:\n"
246
+ f" {str_exc_types}{str_context}\n"
247
+ )
@@ -0,0 +1,69 @@
1
+ """
2
+ Module containing the localised messages that will be displayed in the Marimo
3
+ notebook.
4
+ """
5
+
6
+ _MESSAGES: dict[str, dict[str, str]] = {
7
+ "en": {
8
+ "ok": "ok",
9
+ "failed": "Failed test",
10
+ "error": "Execution error",
11
+ "mock_open_1": (
12
+ "This notebook is executed as a Web Component inside the browser. "
13
+ "Python is not executed in regular operating system on a computer "
14
+ "but inside the Web browser. As such, the standard `open` "
15
+ "function can't be used to read files because, for security "
16
+ "reasons, the Web browser can't access to the computer "
17
+ "filesystem. Therefore, we have replaced the `open` function with "
18
+ "a custom function that can get a file at a given URL and that "
19
+ "emulates the behaviour of the standard function."
20
+ ),
21
+ "mock_open_2": (
22
+ "Please note that we can't emulate all the behaviour of the "
23
+ "standard `open` function because getting a document by "
24
+ "requesting an URL is quite different from reading a file on a "
25
+ "disk. Our custom function has only three parameters: `filename`, "
26
+ "`mode` and `encoding`. This is enough to cover most of the use "
27
+ "cases. Be aware that our custom function can **only read files** "
28
+ '(`mode="r"`) because, as you might suspect, it is impossible '
29
+ "to write files through a Web GET request."
30
+ ),
31
+ },
32
+ "fr": {
33
+ "ok": "ok",
34
+ "failed": "Échec du test",
35
+ "error": "Erreur d'exécution",
36
+ "mock_open_1": (
37
+ "Ce calepin est exécuté comme Composant Web (*Web Component*) "
38
+ "dans le navigateur Web. Python n'est pas exécuté sur le "
39
+ "système d'exploitation classique d'un ordinateur mais dans le "
40
+ "navigateur Web. Ainsi, la fonction standard `open` ne peut pas "
41
+ "être utilisée pour lire des fichiers car le navigateur, pour "
42
+ "des raisons de sécurité, n'a pas accès au système de fichiers "
43
+ "de l'ordinateur. Par conséquent, nous avons remplacé la "
44
+ "fonction `open` par une fonction personnalisée qui récupère un "
45
+ "fichier à une URL donnée et qui émule le comportement de la "
46
+ "fonction standard."
47
+ ),
48
+ "mock_open_2": (
49
+ "Notez que nous ne pouvons pas émuler le comportement complet de "
50
+ "la fonction standard `open` car récupérer un document en "
51
+ "requêtant une URL est très différent de lire un fichier sur "
52
+ "un disque. Notre fonction personnalisée a seulement trois "
53
+ "paramètres: `filename`, `mode` et `encoding`. Cela suffit pour "
54
+ "couvrir la plupart des cas d'utilisation. Soyez bien conscients "
55
+ "que notre fonction personnalisée peut uniquement **lire des "
56
+ 'fichiers** (`mode="r"`) car, comme vous vous en doutez, il est '
57
+ "impossible d'écrire des fichiers au travers d'une requête Web "
58
+ "de type GET."
59
+ ),
60
+ },
61
+ }
62
+
63
+
64
+ def get_message(message_id, lang="en"):
65
+ """
66
+ Get the message corresponding to `message_id` in the `lang` language.
67
+ """
68
+ localised_msgs = _MESSAGES[lang]
69
+ return localised_msgs[message_id]