python-interactive-courses 1.0.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.
- pyics/__init__.py +0 -0
- pyics/app/actions.py +179 -0
- pyics/app/cli.py +335 -0
- pyics/app/key_listener.py +133 -0
- pyics/app/prompts.py +439 -0
- pyics/app/pyics.py +454 -0
- pyics/data/__init__.py +4 -0
- pyics/data/course.py +531 -0
- pyics/data/task.py +744 -0
- pyics/distribution/__init__.py +5 -0
- pyics/distribution/archive.py +667 -0
- pyics/distribution/dependencies.py +452 -0
- pyics/distribution/fetch.py +128 -0
- pyics/distribution/pull.py +376 -0
- pyics/distribution/slugs.py +24 -0
- pyics/distribution/sources.py +561 -0
- pyics/notebook/__init__.py +1 -0
- pyics/notebook/cells.py +184 -0
- pyics/notebook/loader.py +590 -0
- pyics/notebook/sections.py +189 -0
- pyics/plugins.py +90 -0
- pyics/scripts/console.py +86 -0
- pyics/scripts/create_course.py +301 -0
- pyics/scripts/create_task.py +850 -0
- pyics/scripts/edit_course.py +133 -0
- pyics/scripts/edit_task.py +109 -0
- pyics/scripts/main.py +118 -0
- pyics/scripts/pack.py +198 -0
- pyics/scripts/pull.py +123 -0
- pyics/scripts/sources.py +118 -0
- pyics/tests/__init__.py +3 -0
- pyics/tests/capture.py +114 -0
- pyics/tests/check_specs.py +528 -0
- pyics/tests/check_structure.py +769 -0
- pyics/tests/compare.py +184 -0
- pyics/tests/pytest_integration.py +228 -0
- pyics/tests/results.py +145 -0
- pyics/tests/tests.py +440 -0
- pyics/tests/trace.py +156 -0
- pyics/util/discovery.py +92 -0
- pyics/util/json_handling.py +182 -0
- pyics/util/logging.py +39 -0
- pyics/util/paths.py +38 -0
- pyics/util/terminal.py +57 -0
- python_interactive_courses-1.0.0.dist-info/METADATA +245 -0
- python_interactive_courses-1.0.0.dist-info/RECORD +49 -0
- python_interactive_courses-1.0.0.dist-info/WHEEL +4 -0
- python_interactive_courses-1.0.0.dist-info/entry_points.txt +3 -0
- python_interactive_courses-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The packages a course needs besides pyics, declared in its manifest as ``requires`` (PEP
|
|
3
|
+
508 requirements such as ``"numpy>=1.26"``), and the pyics version it needs, declared as
|
|
4
|
+
``requires_pyics`` (a version specifier such as ``">=0.1"``).
|
|
5
|
+
|
|
6
|
+
A course's tests run in the same interpreter as pyics, so the packages have to be
|
|
7
|
+
installed in pyics's own environment. This module checks whether they are, builds the
|
|
8
|
+
command that installs them there, decides which modules a course's task files may make
|
|
9
|
+
json_tricks import (see ``ModulePolicy``), and finds the packages a course uses, so that
|
|
10
|
+
its author can declare them.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import ast
|
|
14
|
+
import importlib.metadata
|
|
15
|
+
import importlib.util
|
|
16
|
+
import re
|
|
17
|
+
import shlex
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
from collections.abc import Collection, Iterable, Mapping, Sequence
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from os import PathLike
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from packaging.requirements import InvalidRequirement, Requirement
|
|
27
|
+
from packaging.specifiers import InvalidSpecifier, SpecifierSet
|
|
28
|
+
from packaging.utils import canonicalize_name
|
|
29
|
+
from packaging.version import InvalidVersion, Version
|
|
30
|
+
|
|
31
|
+
from pyics.notebook import cells, loader
|
|
32
|
+
from pyics.util import json_handling as json
|
|
33
|
+
|
|
34
|
+
# always available to a course: pyics itself, and the standard library
|
|
35
|
+
_ALWAYS_ALLOWED = frozenset({"pyics", *sys.stdlib_module_names})
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class MissingDependencyError(ImportError):
|
|
39
|
+
"""A task needs a package that isn't installed."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ModuleNotAllowedError(Exception):
|
|
43
|
+
"""A task file names a module for json_tricks to import that its course doesn't
|
|
44
|
+
declare (see ``ModulePolicy``)."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def pyics_version() -> str:
|
|
48
|
+
"""The installed version of pyics."""
|
|
49
|
+
try:
|
|
50
|
+
return importlib.metadata.version("pyics")
|
|
51
|
+
except importlib.metadata.PackageNotFoundError:
|
|
52
|
+
return "0"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def installed_version(distribution: str) -> str | None:
|
|
56
|
+
"""The installed version of ``distribution``, or None if it isn't installed."""
|
|
57
|
+
try:
|
|
58
|
+
return importlib.metadata.version(distribution)
|
|
59
|
+
except importlib.metadata.PackageNotFoundError:
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class Problem:
|
|
65
|
+
"""A requirement of a course that isn't met."""
|
|
66
|
+
|
|
67
|
+
# as declared, e.g. "numpy>=1.26", or "pyics>=0.2" for ``requires_pyics``
|
|
68
|
+
requirement: str
|
|
69
|
+
# why it isn't met, e.g. "not installed"
|
|
70
|
+
reason: str
|
|
71
|
+
# whether installing ``requirement`` meets it (pyics itself is upgraded instead)
|
|
72
|
+
installable: bool = True
|
|
73
|
+
|
|
74
|
+
def __str__(self) -> str:
|
|
75
|
+
return f"{self.requirement} ({self.reason})"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _applies(req: Requirement) -> bool:
|
|
79
|
+
"""Whether ``req`` applies to this environment, i.e. its marker (e.g.
|
|
80
|
+
``sys_platform == 'win32'``) is met, if it has one."""
|
|
81
|
+
return req.marker is None or req.marker.evaluate()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def check(
|
|
85
|
+
requires: Iterable[str] = (), requires_pyics: str | None = None
|
|
86
|
+
) -> list[Problem]:
|
|
87
|
+
"""The requirements of ``requires`` and ``requires_pyics`` that aren't met by the
|
|
88
|
+
installed packages."""
|
|
89
|
+
problems: list[Problem] = []
|
|
90
|
+
if requires_pyics:
|
|
91
|
+
try:
|
|
92
|
+
spec = SpecifierSet(requires_pyics)
|
|
93
|
+
except InvalidSpecifier:
|
|
94
|
+
problems.append(
|
|
95
|
+
Problem(
|
|
96
|
+
f"pyics{requires_pyics}", "not a valid version", installable=False
|
|
97
|
+
)
|
|
98
|
+
)
|
|
99
|
+
else:
|
|
100
|
+
if not spec.contains(pyics_version(), prereleases=True):
|
|
101
|
+
problem = Problem(
|
|
102
|
+
f"pyics{requires_pyics}",
|
|
103
|
+
f"you have {pyics_version()}",
|
|
104
|
+
installable=False,
|
|
105
|
+
)
|
|
106
|
+
problems.append(problem)
|
|
107
|
+
|
|
108
|
+
for raw in requires:
|
|
109
|
+
try:
|
|
110
|
+
req = Requirement(raw)
|
|
111
|
+
except InvalidRequirement:
|
|
112
|
+
problems.append(Problem(raw, "not a valid requirement", installable=False))
|
|
113
|
+
continue
|
|
114
|
+
if not _applies(req):
|
|
115
|
+
continue
|
|
116
|
+
version = installed_version(req.name)
|
|
117
|
+
if version is None:
|
|
118
|
+
problems.append(Problem(raw, "not installed"))
|
|
119
|
+
elif not req.specifier.contains(version, prereleases=True):
|
|
120
|
+
problems.append(Problem(raw, f"{version} is installed"))
|
|
121
|
+
return problems
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def manifest_requirements(manifest: Mapping[str, Any]) -> tuple[list[str], str | None]:
|
|
125
|
+
"""
|
|
126
|
+
The ``requires`` and ``requires_pyics`` of a course ``manifest``.
|
|
127
|
+
|
|
128
|
+
Raises
|
|
129
|
+
------
|
|
130
|
+
ValueError
|
|
131
|
+
If they aren't a list of requirements and a version specifier.
|
|
132
|
+
"""
|
|
133
|
+
requires = manifest.get("requires") or []
|
|
134
|
+
if not isinstance(requires, list) or not all(isinstance(r, str) for r in requires):
|
|
135
|
+
raise ValueError("its requires must be a list of requirements, e.g. ['numpy']")
|
|
136
|
+
requires_pyics = manifest.get("requires_pyics") or None
|
|
137
|
+
if requires_pyics is not None and not isinstance(requires_pyics, str):
|
|
138
|
+
raise ValueError("its requires_pyics must be a version specifier, e.g. '>=0.1'")
|
|
139
|
+
return requires, requires_pyics
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def check_manifest(manifest: Mapping[str, Any]) -> list[Problem]:
|
|
143
|
+
"""The requirements of the course ``manifest`` that aren't met (see ``check``)."""
|
|
144
|
+
try:
|
|
145
|
+
requires, requires_pyics = manifest_requirements(manifest)
|
|
146
|
+
except ValueError as err:
|
|
147
|
+
return [Problem("requirements", str(err), installable=False)]
|
|
148
|
+
return check(requires, requires_pyics)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def format_command(command: Sequence[str]) -> str:
|
|
152
|
+
"""``command`` as it would be typed into the platform's shell."""
|
|
153
|
+
if sys.platform == "win32":
|
|
154
|
+
return subprocess.list2cmdline(command)
|
|
155
|
+
return shlex.join(command)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def install_command(requirements: Sequence[str]) -> list[str]:
|
|
159
|
+
"""
|
|
160
|
+
The command that installs ``requirements`` into pyics's own environment, where the
|
|
161
|
+
tests of courses are run: pip, run by this interpreter, or uv pointed at it if pip
|
|
162
|
+
isn't installed (e.g. in environments created by uv).
|
|
163
|
+
"""
|
|
164
|
+
if importlib.util.find_spec("pip") is not None:
|
|
165
|
+
return [sys.executable, "-m", "pip", "install", *requirements]
|
|
166
|
+
return ["uv", "pip", "install", "--python", sys.executable, *requirements]
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _tool_install_note(requirements: Sequence[str]) -> str | None:
|
|
170
|
+
"""How to keep ``requirements`` installed along pyics if it is installed as a
|
|
171
|
+
tool by uv or pipx, which recreate its environment when it is upgraded."""
|
|
172
|
+
parts = [part.lower() for part in Path(sys.prefix).parts]
|
|
173
|
+
if "pipx" in parts and "venvs" in parts:
|
|
174
|
+
return f"pyics is installed with pipx, so you can also run: pipx inject pyics {' '.join(requirements)}"
|
|
175
|
+
if "tools" in parts and any("uv" in part for part in parts):
|
|
176
|
+
withs = " ".join(f"--with {shlex.quote(r)}" for r in requirements)
|
|
177
|
+
return (
|
|
178
|
+
"pyics is installed as a uv tool: to keep these packages when it is "
|
|
179
|
+
f"upgraded, reinstall it with: uv tool install pyics {withs}"
|
|
180
|
+
)
|
|
181
|
+
return None
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def install_hint(problems: Iterable[Problem]) -> str:
|
|
185
|
+
"""How to meet the requirements of ``problems``, as one or more sentences."""
|
|
186
|
+
problems = list(problems)
|
|
187
|
+
installable = [p.requirement for p in problems if p.installable]
|
|
188
|
+
hints: list[str] = []
|
|
189
|
+
if installable:
|
|
190
|
+
command = format_command(install_command(installable))
|
|
191
|
+
hints.append(f"Install {_them(installable)} with: {command}")
|
|
192
|
+
if note := _tool_install_note(installable):
|
|
193
|
+
hints.append(note)
|
|
194
|
+
if any(p.requirement.startswith("pyics") and not p.installable for p in problems):
|
|
195
|
+
hints.append(
|
|
196
|
+
"Upgrade pyics with: " + format_command(install_command(["-U", "pyics"]))
|
|
197
|
+
)
|
|
198
|
+
return " ".join(hints)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _them(items: Collection[str]) -> str:
|
|
202
|
+
return "it" if len(items) == 1 else "them"
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def describe(problems: Iterable[Problem]) -> str:
|
|
206
|
+
"""``problems`` as a list in a sentence, e.g. "numpy (not installed) and pandas (1.0
|
|
207
|
+
is installed)"."""
|
|
208
|
+
texts = [str(p) for p in problems]
|
|
209
|
+
if len(texts) <= 1:
|
|
210
|
+
return "".join(texts)
|
|
211
|
+
return ", ".join(texts[:-1]) + " and " + texts[-1]
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def install(requirements: Sequence[str]) -> int:
|
|
215
|
+
"""Install ``requirements`` into pyics's environment (see ``install_command``),
|
|
216
|
+
showing the installer's output. Returns its exit code."""
|
|
217
|
+
try:
|
|
218
|
+
return subprocess.run(install_command(requirements)).returncode
|
|
219
|
+
except FileNotFoundError: # no uv either
|
|
220
|
+
return 127
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
# --- modules ---
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _distributions_by_module() -> dict[str, list[str]]:
|
|
227
|
+
"""The installed distributions that provide each top-level module."""
|
|
228
|
+
return importlib.metadata.packages_distributions()
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def import_names(requires: Iterable[str]) -> set[str]:
|
|
232
|
+
"""
|
|
233
|
+
The top-level modules the requirements of ``requires`` provide, e.g. ``yaml`` for
|
|
234
|
+
``PyYAML``. Modules of distributions that aren't installed can't be looked up, so
|
|
235
|
+
their names are guessed from the distributions' names (e.g. ``scikit_learn``).
|
|
236
|
+
"""
|
|
237
|
+
wanted: set[str] = set()
|
|
238
|
+
for raw in requires:
|
|
239
|
+
try:
|
|
240
|
+
name = Requirement(raw).name
|
|
241
|
+
except InvalidRequirement:
|
|
242
|
+
continue
|
|
243
|
+
wanted.add(canonicalize_name(name))
|
|
244
|
+
names = {name.replace("-", "_") for name in wanted}
|
|
245
|
+
if not wanted: # the lookup below takes a while
|
|
246
|
+
return names
|
|
247
|
+
for module, distributions in _distributions_by_module().items():
|
|
248
|
+
if any(canonicalize_name(d) in wanted for d in distributions):
|
|
249
|
+
names.add(module)
|
|
250
|
+
return names
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def distribution_of(module: str) -> str:
|
|
254
|
+
"""The name of the installed distribution that provides the top-level ``module``,
|
|
255
|
+
or ``module`` itself if none does."""
|
|
256
|
+
return _distributions_by_module().get(module, [module])[0]
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _top_level(module: str) -> str:
|
|
260
|
+
return module.split(".")[0]
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
class ModulePolicy:
|
|
264
|
+
"""
|
|
265
|
+
The modules that the task files of a course may name for json_tricks to import, to
|
|
266
|
+
decode the objects stored in them (e.g. a test's expected value): those of pyics,
|
|
267
|
+
of the standard library, and of the packages the course declares in its
|
|
268
|
+
``requires``. Undeclared modules are refused before anything is decoded, so that a
|
|
269
|
+
task file can't make pyics import any module that happens to be installed.
|
|
270
|
+
|
|
271
|
+
This is a safeguard against undeclared or misspelled modules, not a sandbox: the
|
|
272
|
+
tests of a course run its code anyway.
|
|
273
|
+
"""
|
|
274
|
+
|
|
275
|
+
def __init__(self, requires: Iterable[str] = ()) -> None:
|
|
276
|
+
self.requires = list(requires)
|
|
277
|
+
self._allowed = _ALWAYS_ALLOWED | import_names(self.requires)
|
|
278
|
+
|
|
279
|
+
def allows(self, module: str | None) -> bool:
|
|
280
|
+
"""Whether a task file may name ``module`` (None for a class defined in a
|
|
281
|
+
script, which can't be imported)."""
|
|
282
|
+
return module is not None and _top_level(module) in self._allowed
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _requirement_for(module: str, requires: Sequence[str]) -> str:
|
|
286
|
+
"""The declared requirement that provides the top-level ``module``, or the name of
|
|
287
|
+
the distribution that would."""
|
|
288
|
+
for raw in requires:
|
|
289
|
+
if module in import_names([raw]):
|
|
290
|
+
return raw
|
|
291
|
+
return distribution_of(module)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def check_task_data(
|
|
295
|
+
data: Any, task_file: object, policy: ModulePolicy | None = None
|
|
296
|
+
) -> None:
|
|
297
|
+
"""
|
|
298
|
+
Check that the task data ``data``, as read by ``json_handling.loads_plain`` from
|
|
299
|
+
``task_file``, can be decoded: that it names only modules ``policy`` allows, if
|
|
300
|
+
given, and that what decoding it needs is installed.
|
|
301
|
+
|
|
302
|
+
Raises
|
|
303
|
+
------
|
|
304
|
+
ModuleNotAllowedError
|
|
305
|
+
If it names a module the policy doesn't allow.
|
|
306
|
+
MissingDependencyError
|
|
307
|
+
If it needs a package that isn't installed.
|
|
308
|
+
"""
|
|
309
|
+
tags = json.scan_tags(data)
|
|
310
|
+
if policy is not None:
|
|
311
|
+
for module in sorted(tags.modules, key=str):
|
|
312
|
+
if policy.allows(module):
|
|
313
|
+
continue
|
|
314
|
+
if module is None:
|
|
315
|
+
raise ModuleNotAllowedError(
|
|
316
|
+
f"The task file {task_file} holds an object of a class that was "
|
|
317
|
+
"defined in a script, which can't be loaded."
|
|
318
|
+
)
|
|
319
|
+
raise ModuleNotAllowedError(
|
|
320
|
+
f"The task file {task_file} uses the module {module!r}, which its "
|
|
321
|
+
f"course doesn't declare: add the package that provides it (e.g. "
|
|
322
|
+
f"{distribution_of(_top_level(module))!r}) to the requires of the "
|
|
323
|
+
"course manifest."
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
requires = policy.requires if policy is not None else []
|
|
327
|
+
needed = {_top_level(m) for m in tags.modules if m is not None} | tags.packages
|
|
328
|
+
for module in sorted(needed):
|
|
329
|
+
if importlib.util.find_spec(module) is not None:
|
|
330
|
+
continue
|
|
331
|
+
requirement = _requirement_for(module, requires)
|
|
332
|
+
raise MissingDependencyError(
|
|
333
|
+
f"This course needs {requirement}, which isn't installed. "
|
|
334
|
+
+ install_hint([Problem(requirement, "not installed")]),
|
|
335
|
+
name=module,
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
# --- finding the packages a course uses ---
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def imported_modules(code: str) -> set[str]:
|
|
343
|
+
"""The top-level modules ``code`` imports absolutely. Code that can't be parsed
|
|
344
|
+
(e.g. an unfinished exercise) imports none."""
|
|
345
|
+
try:
|
|
346
|
+
tree = ast.parse(code)
|
|
347
|
+
except SyntaxError:
|
|
348
|
+
return set()
|
|
349
|
+
modules: set[str] = set()
|
|
350
|
+
for node in ast.walk(tree):
|
|
351
|
+
if isinstance(node, ast.Import):
|
|
352
|
+
modules.update(_top_level(alias.name) for alias in node.names)
|
|
353
|
+
elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module:
|
|
354
|
+
modules.add(_top_level(node.module))
|
|
355
|
+
return modules
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _python_code(code: str, file: object) -> str:
|
|
359
|
+
"""``code``, the contents stored for ``file``, as Python: for a notebook, the code
|
|
360
|
+
of its cells, with magics turned into Python (see ``loader.python_source``)."""
|
|
361
|
+
if not isinstance(file, str | PathLike) or not cells.is_notebook(file):
|
|
362
|
+
return code
|
|
363
|
+
try:
|
|
364
|
+
return loader.python_source(cells.parse(code)).source
|
|
365
|
+
except cells.NotebookError:
|
|
366
|
+
return ""
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
# a notebook line that installs packages, e.g. ``%pip install numpy`` or ``!pip install
|
|
370
|
+
# -q numpy``
|
|
371
|
+
_PIP_INSTALL = re.compile(r"[%!]\s*(?:pip3?|python3? -m pip)\s+install\b")
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def pip_install_lines(code: str) -> list[tuple[int, str]]:
|
|
375
|
+
"""
|
|
376
|
+
The lines of the code cells of the notebook ``code`` (the contents of an ``.ipynb``
|
|
377
|
+
file) that install packages, e.g. ``%pip install numpy``, with the number of their
|
|
378
|
+
cell (see ``Cell.number``). They hint at the packages a course needs, which tests
|
|
379
|
+
don't install (see ``loader``). Empty if ``code`` isn't a notebook.
|
|
380
|
+
"""
|
|
381
|
+
try:
|
|
382
|
+
code_cells = cells.code_cells(cells.parse(code))
|
|
383
|
+
except cells.NotebookError:
|
|
384
|
+
return []
|
|
385
|
+
return [
|
|
386
|
+
(cell.number, line.strip())
|
|
387
|
+
for cell in code_cells
|
|
388
|
+
for line in cell.source.splitlines()
|
|
389
|
+
if _PIP_INSTALL.match(line.strip())
|
|
390
|
+
]
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def used_modules(
|
|
394
|
+
tasks: Mapping[str, Any], local: Collection[str] = ()
|
|
395
|
+
) -> dict[str, set[str]]:
|
|
396
|
+
"""
|
|
397
|
+
The top-level modules besides pyics and the standard library that the task data
|
|
398
|
+
of ``tasks`` (by task file name, as read by ``json_handling.loads_plain``) use: to
|
|
399
|
+
decode the objects stored in them, and imported by their exercise and solution
|
|
400
|
+
code (of notebooks, by their code cells). Modules of the course itself (``local``)
|
|
401
|
+
are left out.
|
|
402
|
+
|
|
403
|
+
Returns
|
|
404
|
+
-------
|
|
405
|
+
dict[str, set[str]]
|
|
406
|
+
The modules, mapped to the names of the task files that use them.
|
|
407
|
+
"""
|
|
408
|
+
used: dict[str, set[str]] = {}
|
|
409
|
+
for name, data in tasks.items():
|
|
410
|
+
tags = json.scan_tags(data)
|
|
411
|
+
modules = {_top_level(m) for m in tags.modules if m is not None}
|
|
412
|
+
modules |= tags.packages
|
|
413
|
+
code_keys = (("exercise_code", "ex_files"), ("solution_code", "sol_files"))
|
|
414
|
+
for key, files_key in code_keys:
|
|
415
|
+
files = data.get(files_key)
|
|
416
|
+
files = files if isinstance(files, list) else []
|
|
417
|
+
for i, code in enumerate(data.get(key) or []):
|
|
418
|
+
if isinstance(code, str):
|
|
419
|
+
file = files[i] if i < len(files) else None
|
|
420
|
+
modules |= imported_modules(_python_code(code, file))
|
|
421
|
+
for module in modules - _ALWAYS_ALLOWED - set(local) - {"__future__"}:
|
|
422
|
+
used.setdefault(module, set()).add(name)
|
|
423
|
+
return used
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def undeclared(
|
|
427
|
+
used: Mapping[str, set[str]], requires: Iterable[str]
|
|
428
|
+
) -> dict[str, set[str]]:
|
|
429
|
+
"""
|
|
430
|
+
The distributions that provide the modules of ``used`` (see ``used_modules``) but
|
|
431
|
+
aren't declared in ``requires``.
|
|
432
|
+
|
|
433
|
+
Returns
|
|
434
|
+
-------
|
|
435
|
+
dict[str, set[str]]
|
|
436
|
+
The distributions' names, mapped to the names of the task files that use them.
|
|
437
|
+
"""
|
|
438
|
+
declared = import_names(requires)
|
|
439
|
+
missing: dict[str, set[str]] = {}
|
|
440
|
+
for module, files in used.items():
|
|
441
|
+
if module not in declared:
|
|
442
|
+
missing.setdefault(distribution_of(module), set()).update(files)
|
|
443
|
+
return missing
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def is_newer(version: str, than: str) -> bool:
|
|
447
|
+
"""Whether the course version ``version`` is newer than ``than``. Versions that
|
|
448
|
+
aren't PEP 440 versions are compared as text."""
|
|
449
|
+
try:
|
|
450
|
+
return Version(version) > Version(than)
|
|
451
|
+
except InvalidVersion:
|
|
452
|
+
return version > than
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Downloading indexes and course archives: over HTTPS, or from local files and ``file:``
|
|
3
|
+
URLs, e.g. on a network share. Plain HTTP is used only where it is allowed explicitly.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import re
|
|
7
|
+
import ssl
|
|
8
|
+
import urllib.error
|
|
9
|
+
import urllib.parse
|
|
10
|
+
import urllib.request
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from pyics.distribution.dependencies import pyics_version
|
|
14
|
+
|
|
15
|
+
TIMEOUT = 30 # seconds
|
|
16
|
+
_URL = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]+://")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class FetchError(Exception):
|
|
20
|
+
"""A URL can't, or mustn't, be fetched."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def is_url(location: str) -> bool:
|
|
24
|
+
"""Whether ``location`` is a URL, rather than a path."""
|
|
25
|
+
return bool(_URL.match(location))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def to_url(location: str) -> str:
|
|
29
|
+
"""``location`` as a URL: URLs are kept as they are, and paths (e.g.
|
|
30
|
+
``./courses/index.json``) become ``file:`` URLs."""
|
|
31
|
+
if is_url(location):
|
|
32
|
+
return location
|
|
33
|
+
return Path(location).expanduser().absolute().as_uri()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def scheme(url: str) -> str:
|
|
37
|
+
return urllib.parse.urlsplit(url).scheme.lower()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def check_url(url: str, *, allow_http: bool = False, allow_file: bool = True) -> None:
|
|
41
|
+
"""
|
|
42
|
+
Raise ``FetchError`` if ``url`` mustn't be fetched: only HTTPS URLs, HTTP URLs if
|
|
43
|
+
``allow_http``, and ``file:`` URLs if ``allow_file`` may be.
|
|
44
|
+
"""
|
|
45
|
+
match scheme(url):
|
|
46
|
+
case "https":
|
|
47
|
+
return
|
|
48
|
+
case "http" if allow_http:
|
|
49
|
+
return
|
|
50
|
+
case "file" if allow_file:
|
|
51
|
+
return
|
|
52
|
+
case "http":
|
|
53
|
+
raise FetchError(
|
|
54
|
+
f"{url} isn't secure: use an https URL, or allow plain http with "
|
|
55
|
+
"--allow_http"
|
|
56
|
+
)
|
|
57
|
+
case "file":
|
|
58
|
+
raise FetchError(f"{url} is a local file, which a web source can't link to")
|
|
59
|
+
case _:
|
|
60
|
+
raise FetchError(f"{url} can't be fetched: only https URLs are supported")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _ssl_context() -> ssl.SSLContext:
|
|
64
|
+
"""The system's certificates, through ``truststore`` if it is installed (some
|
|
65
|
+
Python installations, e.g. from python.org on macOS, don't find them otherwise)."""
|
|
66
|
+
try:
|
|
67
|
+
import truststore
|
|
68
|
+
except ImportError:
|
|
69
|
+
return ssl.create_default_context()
|
|
70
|
+
return truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _read_file(url: str, max_bytes: int) -> bytes:
|
|
74
|
+
"""The contents of the ``file:`` URL ``url``, also of a network share on Windows
|
|
75
|
+
(e.g. ``file://server/share/index.json``), which urllib can't open."""
|
|
76
|
+
try:
|
|
77
|
+
with open(Path.from_uri(url), "rb") as f:
|
|
78
|
+
data = f.read(max_bytes + 1)
|
|
79
|
+
except (OSError, ValueError) as err:
|
|
80
|
+
raise FetchError(f"Can't fetch {url}: {err}") from err
|
|
81
|
+
if len(data) > max_bytes:
|
|
82
|
+
raise FetchError(f"{url} is larger than {max_bytes // 2**20} MB")
|
|
83
|
+
return data
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def fetch(
|
|
87
|
+
url: str, *, max_bytes: int, allow_http: bool = False, allow_file: bool = True
|
|
88
|
+
) -> bytes:
|
|
89
|
+
"""
|
|
90
|
+
The contents of ``url``, at most ``max_bytes`` of them (see ``check_url`` for the
|
|
91
|
+
URLs that may be fetched, also after redirects).
|
|
92
|
+
|
|
93
|
+
Raises
|
|
94
|
+
------
|
|
95
|
+
FetchError
|
|
96
|
+
If it mustn't or can't be fetched, or is larger than ``max_bytes``.
|
|
97
|
+
"""
|
|
98
|
+
check_url(url, allow_http=allow_http, allow_file=allow_file)
|
|
99
|
+
if scheme(url) == "file":
|
|
100
|
+
return _read_file(url, max_bytes)
|
|
101
|
+
request = urllib.request.Request(
|
|
102
|
+
url, headers={"User-Agent": f"pyics/{pyics_version()}"}
|
|
103
|
+
)
|
|
104
|
+
try:
|
|
105
|
+
with urllib.request.urlopen(
|
|
106
|
+
request, timeout=TIMEOUT, context=_ssl_context()
|
|
107
|
+
) as response:
|
|
108
|
+
# redirects may lead elsewhere, e.g. from https to http
|
|
109
|
+
check_url(response.geturl(), allow_http=allow_http, allow_file=False)
|
|
110
|
+
data = response.read(max_bytes + 1)
|
|
111
|
+
except urllib.error.HTTPError as err:
|
|
112
|
+
raise FetchError(f"Can't fetch {url}: {err.code} {err.reason}") from err
|
|
113
|
+
except urllib.error.URLError as err:
|
|
114
|
+
if isinstance(err.reason, ssl.SSLCertVerificationError):
|
|
115
|
+
host = urllib.parse.urlsplit(url).hostname
|
|
116
|
+
raise FetchError(
|
|
117
|
+
f"Can't fetch {url}: the certificate of {host} can't be verified "
|
|
118
|
+
f"({err.reason.verify_message}). If your network's or your system's "
|
|
119
|
+
"certificates are the problem, `pip install truststore` makes pyics "
|
|
120
|
+
"use your system's certificates. With Python from python.org on macOS, "
|
|
121
|
+
"run its 'Install Certificates.command' instead."
|
|
122
|
+
) from err
|
|
123
|
+
raise FetchError(f"Can't fetch {url}: {err.reason}") from err
|
|
124
|
+
except (OSError, ValueError) as err: # e.g. a timeout, or a malformed URL
|
|
125
|
+
raise FetchError(f"Can't fetch {url}: {err}") from err
|
|
126
|
+
if len(data) > max_bytes:
|
|
127
|
+
raise FetchError(f"{url} is larger than {max_bytes // 2**20} MB")
|
|
128
|
+
return data
|