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.
Files changed (49) hide show
  1. pyics/__init__.py +0 -0
  2. pyics/app/actions.py +179 -0
  3. pyics/app/cli.py +335 -0
  4. pyics/app/key_listener.py +133 -0
  5. pyics/app/prompts.py +439 -0
  6. pyics/app/pyics.py +454 -0
  7. pyics/data/__init__.py +4 -0
  8. pyics/data/course.py +531 -0
  9. pyics/data/task.py +744 -0
  10. pyics/distribution/__init__.py +5 -0
  11. pyics/distribution/archive.py +667 -0
  12. pyics/distribution/dependencies.py +452 -0
  13. pyics/distribution/fetch.py +128 -0
  14. pyics/distribution/pull.py +376 -0
  15. pyics/distribution/slugs.py +24 -0
  16. pyics/distribution/sources.py +561 -0
  17. pyics/notebook/__init__.py +1 -0
  18. pyics/notebook/cells.py +184 -0
  19. pyics/notebook/loader.py +590 -0
  20. pyics/notebook/sections.py +189 -0
  21. pyics/plugins.py +90 -0
  22. pyics/scripts/console.py +86 -0
  23. pyics/scripts/create_course.py +301 -0
  24. pyics/scripts/create_task.py +850 -0
  25. pyics/scripts/edit_course.py +133 -0
  26. pyics/scripts/edit_task.py +109 -0
  27. pyics/scripts/main.py +118 -0
  28. pyics/scripts/pack.py +198 -0
  29. pyics/scripts/pull.py +123 -0
  30. pyics/scripts/sources.py +118 -0
  31. pyics/tests/__init__.py +3 -0
  32. pyics/tests/capture.py +114 -0
  33. pyics/tests/check_specs.py +528 -0
  34. pyics/tests/check_structure.py +769 -0
  35. pyics/tests/compare.py +184 -0
  36. pyics/tests/pytest_integration.py +228 -0
  37. pyics/tests/results.py +145 -0
  38. pyics/tests/tests.py +440 -0
  39. pyics/tests/trace.py +156 -0
  40. pyics/util/discovery.py +92 -0
  41. pyics/util/json_handling.py +182 -0
  42. pyics/util/logging.py +39 -0
  43. pyics/util/paths.py +38 -0
  44. pyics/util/terminal.py +57 -0
  45. python_interactive_courses-1.0.0.dist-info/METADATA +245 -0
  46. python_interactive_courses-1.0.0.dist-info/RECORD +49 -0
  47. python_interactive_courses-1.0.0.dist-info/WHEEL +4 -0
  48. python_interactive_courses-1.0.0.dist-info/entry_points.txt +3 -0
  49. 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