From 731cc00103636b25bd89605d2c0fcb2ef6e0954d Mon Sep 17 00:00:00 2001 From: Li-Ta Hsu Date: Sun, 23 Aug 2026 23:17:06 +0800 Subject: [PATCH] Make every example import the core sitting next to it (#86) `python ch4_rf_point_positioning/example_toa_positioning.py` puts the *chapter* directory on `sys.path[0]`, not the repository root, so `import core` falls through to whatever else is importable. On a fresh clone that is ModuleNotFoundError. On a machine that has ever installed this package it is worse and quieter: measured in this worktree, a probe run as a script from the chapter directory resolved `core` to `C:/Users/qmohs/IPIN-Examples/core` -- a different checkout -- and would have run to completion against it. No error; only the answer changes. All 38 examples now insert the repository root before their first `core` import. This is not a new idiom: `ch5_fingerprinting/example_classification` has carried the same line for as long as it has existed, and nine of the twelve generators in `scripts/` do the same. It had simply never been brought to the other 37. (The three `scripts/` files without it import no `core` at all, so they need none.) `tests/test_examples_import_this_checkout.py` holds it, and checks *order* rather than presence, because a bootstrap below the import changes nothing. Both mutations fire: removing the line, and moving it after the import. Three bugs in the sweep script, all found by pyflakes rather than by reading, each a plausible way to locate an insertion point: - `ast.walk` to test whether `sys` was imported counts an `import sys` inside a *function*, so four files got a module-level `sys.path.insert` with no `sys` bound. - Locating the stdlib group by `line.startswith(("import ", "from "))` matched a line of module *docstring prose* beginning "from range measurements ...", writing `import sys` inside the docstring. - Accepting any module-level import of a name ignores position: ch7's pose-graph example imports `pathlib.Path` three lines *below* its first `core` import, so the name existed but not yet where the bootstrap runs. All three compile. `test_the_check_reads_the_shapes_it_has_to_distinguish` pins the last two against the guard's own parser, since that is the half most likely to agree with itself. Lint moved the right way. Inserting `import sys` at the head of a stdlib group is unsorted, so I001 went 113 -> 147; `ruff --select I001 --fix` over the chapter directories cleared 63 of them, 29 of which predated this change, leaving 84. Baseline lowered accordingly. E402 does not fire at all -- ruff exempts imports following a `sys.path` manipulation, which is what makes the idiom viable. Verified by capturing `--help` for all 38 examples before the change and diffing after: byte-identical, twice -- once after the insertions and again after ruff reordered the imports. `--help` exits during argument parsing but only after every module-level import has run, so it exercises exactly what this touches. The README's paragraph contrasting the two invocation forms said the script form only works once the package is installed. That is no longer true, and it now says what the line is for instead. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 44 +++++ README.md | 15 +- ch2_coords/example_attitude_visualization.py | 8 + ch2_coords/example_coordinate_transforms.py | 10 +- ch3_estimators/example_comparison.py | 25 ++- ch3_estimators/example_ekf_range_bearing.py | 11 +- ch3_estimators/example_iekf_range_bearing.py | 10 +- ch3_estimators/example_kalman_1d.py | 11 +- ch3_estimators/example_least_squares.py | 19 ++- ch3_estimators/example_particle_bimodal.py | 15 +- .../example_aoa_positioning.py | 8 + .../example_comparison.py | 8 + .../example_dop_geometry.py | 15 +- .../example_tdoa_positioning.py | 10 +- .../example_toa_positioning.py | 8 + ch5_fingerprinting/example_classification.py | 2 - ch5_fingerprinting/example_comparison.py | 21 ++- ch5_fingerprinting/example_deterministic.py | 15 +- .../example_pattern_recognition.py | 15 +- ch5_fingerprinting/example_probabilistic.py | 17 +- ch5_fingerprinting/example_walk_posterior.py | 15 +- ch6_dead_reckoning/example_allan_variance.py | 14 +- ch6_dead_reckoning/example_comparison.py | 8 + ch6_dead_reckoning/example_environment.py | 19 ++- ch6_dead_reckoning/example_imu_strapdown.py | 17 +- ch6_dead_reckoning/example_pdr.py | 25 +-- ch6_dead_reckoning/example_wheel_odometry.py | 15 +- ch6_dead_reckoning/example_zupt.py | 24 ++- ch7_slam/example_bundle_adjustment.py | 20 ++- ch7_slam/example_pose_graph_slam.py | 25 ++- .../example_scan_matching_visualization.py | 15 +- ch7_slam/example_slam_frontend.py | 15 +- ch8_sensor_fusion/example_anchor_outage.py | 15 +- ch8_sensor_fusion/example_calibration.py | 11 +- ch8_sensor_fusion/example_comparison.py | 8 + ch8_sensor_fusion/example_lc_fusion.py | 8 + ch8_sensor_fusion/example_observability.py | 8 + ch8_sensor_fusion/example_robust_tuning.py | 18 ++- ch8_sensor_fusion/example_tc_fusion.py | 8 + .../example_temporal_calibration.py | 19 ++- tests/test_examples_import_this_checkout.py | 152 ++++++++++++++++++ tests/test_lint_debt_only_shrinks.py | 2 +- 42 files changed, 643 insertions(+), 105 deletions(-) create mode 100644 tests/test_examples_import_this_checkout.py diff --git a/CLAUDE.md b/CLAUDE.md index 9633b19..53e1f3a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1097,6 +1097,50 @@ Iterative Weighted Least Squares (I-WLS)". The second is the one to fix first if only one gets done -- its stdout is the transcript pinned in the chapter README, so it is the most-read wrong label of the three. +## Every example now bootstraps its own sys.path, and the sweep took three tries + +The trap at the top of this file -- `python chX/example.py` resolving `core` to +the main checkout -- is closed at the source now: all 38 examples insert the +repository root before their first `core` import, matching what +`ch5_fingerprinting/example_classification` and nine of twelve `scripts/` +generators already did. `tests/test_examples_import_this_checkout.py` holds it, +and checks *order* rather than presence, because a bootstrap below the import +changes nothing. + +Two things worth knowing before repeating this kind of sweep. + +**Three of the three bugs in the sweep script were found by pyflakes, none by +reading.** Each was a plausible-looking way to locate an insertion point: + +1. `ast.walk` to decide whether `sys` was already imported -- it counts an + `import sys` inside a *function*, so four files got a module-level + `sys.path.insert` with no `sys` bound. +2. Finding the stdlib group with `line.startswith(("import ", "from "))` -- + which matched a line of *module docstring prose* beginning "from range + measurements ...", and wrote `import sys` inside the docstring. +3. Treating any module-level import of a name as sufficient -- ch7's pose-graph + example imports `pathlib.Path` three lines *below* its first `core` import, + so the name existed but not yet at the point the bootstrap runs. + +All three produce files that `compileall` accepts. The rule from the earlier +lint sweep holds and is worth restating in the stronger form: **a tool that +locates Python by line prefix cannot tell an import from prose that starts like +one, and `ast.walk` cannot tell module scope from function scope.** Use +`tree.body`, and compare line numbers. + +**The import-order ratchet fired, correctly, and fixing it improved the +baseline.** Inserting `import sys` at the top of a stdlib group is unsorted, so +I001 went 113 -> 147. `ruff --select I001 --fix` over the chapter directories +cleared 63, including 29 that predated this change, leaving **84** -- so the +baseline moved down, not up. E402 does *not* fire: ruff exempts imports that +follow a `sys.path` manipulation, which is what makes this idiom viable at all. + +Verification was the cheap strong one: `--help` for all 38 examples, captured +before and diffed after, **byte-identical both times** -- once after the +insertions and again after ruff reordered the imports. `--help` exits during +argument parsing but only after every module-level import has run, so it tests +exactly what this change touches, in about a second per example. + ## A scratch probe in the scratchpad imports the *other* checkout The editable-install trap at the top of this file has a second face that the diff --git a/README.md b/README.md index d206726..d39f429 100644 --- a/README.md +++ b/README.md @@ -155,10 +155,17 @@ python -m ch6_dead_reckoning.example_comparison ``` `python -m` puts the repository root on `sys.path`, so these run straight from -a fresh clone even before step 4 above. The script form — -`python /.py` — puts the *script's* directory there instead, -so `core` is only importable once the package is installed. That is why every -command in this repository is written as `python -m`. +a fresh clone even before step 4 above, and it is the form every command in +this repository is written in. + +The script form — `python /.py` — puts the *script's* +directory there instead, so `core` would not be importable from a fresh clone. +Each example now adds the repository root itself before importing `core`, so +that form works too. It is worth knowing why the line is there: without it, on +a machine that has ever installed this package, `import core` does not fail — +it quietly resolves to **whichever checkout the install points at**, and the +example runs to completion against a different copy of the library. The error, +when there is one, names a directory you have never heard of. Examples find their datasets from any working directory, so `cd`-ing into a chapter folder first is fine. Every example takes `--help`, which prints what diff --git a/ch2_coords/example_attitude_visualization.py b/ch2_coords/example_attitude_visualization.py index 8fd6746..f71e010 100644 --- a/ch2_coords/example_attitude_visualization.py +++ b/ch2_coords/example_attitude_visualization.py @@ -33,11 +33,19 @@ """ import argparse +import sys from pathlib import Path import matplotlib.pyplot as plt import numpy as np +# `core` must come from this checkout. Running this file as a script puts +# its *chapter* directory on sys.path[0], not the repository root, so +# without this line `import core` silently resolves to whatever else is +# installed -- another clone, a stale editable install -- or fails outright +# on a fresh one. See issue #86. +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + from core.coords import ( enu_to_ned, euler_to_rotation_matrix, diff --git a/ch2_coords/example_coordinate_transforms.py b/ch2_coords/example_coordinate_transforms.py index 0b6624d..77fa607 100644 --- a/ch2_coords/example_coordinate_transforms.py +++ b/ch2_coords/example_coordinate_transforms.py @@ -24,11 +24,18 @@ import argparse import json +import sys from pathlib import Path import numpy as np -from core.utils import angle_diff, resolve_data_path +# `core` must come from this checkout. Running this file as a script puts +# its *chapter* directory on sys.path[0], not the repository root, so +# without this line `import core` silently resolves to whatever else is +# installed -- another clone, a stale editable install -- or fails outright +# on a fresh one. See issue #86. +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + from core.coords import ( ecef_to_enu, ecef_to_llh, @@ -41,6 +48,7 @@ quat_to_rotation_matrix, rotation_matrix_to_euler, ) +from core.utils import angle_diff, resolve_data_path def load_dataset(data_dir: str) -> dict: diff --git a/ch3_estimators/example_comparison.py b/ch3_estimators/example_comparison.py index e4d4a9f..c967030 100644 --- a/ch3_estimators/example_comparison.py +++ b/ch3_estimators/example_comparison.py @@ -28,12 +28,28 @@ import argparse import contextlib import io +import sys import time from pathlib import Path -import numpy as np import matplotlib.pyplot as plt +import numpy as np from tqdm import tqdm + +# `core` must come from this checkout. Running this file as a script puts +# its *chapter* directory on sys.path[0], not the repository root, so +# without this line `import core` silently resolves to whatever else is +# installed -- another clone, a stale editable install -- or fails outright +# on a fresh one. See issue #86. +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from core.estimators import ( + ExtendedKalmanFilter, + Factor, + FactorGraph, + ParticleFilter, + UnscentedKalmanFilter, +) from core.eval import ( plot_error_cdf, plot_error_magnitude_time, @@ -41,13 +57,6 @@ save_figure, show_figures_if_requested, ) -from core.estimators import ( - ExtendedKalmanFilter, - UnscentedKalmanFilter, - ParticleFilter, - Factor, - FactorGraph, -) def setup_scenario(seed=42): diff --git a/ch3_estimators/example_ekf_range_bearing.py b/ch3_estimators/example_ekf_range_bearing.py index 15dd551..38e577d 100644 --- a/ch3_estimators/example_ekf_range_bearing.py +++ b/ch3_estimators/example_ekf_range_bearing.py @@ -22,11 +22,20 @@ import argparse import json +import sys from pathlib import Path from typing import Dict -import numpy as np import matplotlib.pyplot as plt +import numpy as np + +# `core` must come from this checkout. Running this file as a script puts +# its *chapter* directory on sys.path[0], not the repository root, so +# without this line `import core` silently resolves to whatever else is +# installed -- another clone, a stale editable install -- or fails outright +# on a fresh one. See issue #86. +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + from core.estimators import ExtendedKalmanFilter from core.eval import save_figure, show_figures_if_requested from core.utils import angle_diff, resolve_data_path diff --git a/ch3_estimators/example_iekf_range_bearing.py b/ch3_estimators/example_iekf_range_bearing.py index 88896f8..048ab17 100644 --- a/ch3_estimators/example_iekf_range_bearing.py +++ b/ch3_estimators/example_iekf_range_bearing.py @@ -29,10 +29,18 @@ """ import argparse +import sys from pathlib import Path -import numpy as np import matplotlib.pyplot as plt +import numpy as np + +# `core` must come from this checkout. Running this file as a script puts +# its *chapter* directory on sys.path[0], not the repository root, so +# without this line `import core` silently resolves to whatever else is +# installed -- another clone, a stale editable install -- or fails outright +# on a fresh one. See issue #86. +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from core.estimators import ExtendedKalmanFilter, IteratedExtendedKalmanFilter from core.eval import ( diff --git a/ch3_estimators/example_kalman_1d.py b/ch3_estimators/example_kalman_1d.py index 68fa66f..d6d0a4e 100644 --- a/ch3_estimators/example_kalman_1d.py +++ b/ch3_estimators/example_kalman_1d.py @@ -20,10 +20,19 @@ """ import argparse +import sys from pathlib import Path -import numpy as np import matplotlib.pyplot as plt +import numpy as np + +# `core` must come from this checkout. Running this file as a script puts +# its *chapter* directory on sys.path[0], not the repository root, so +# without this line `import core` silently resolves to whatever else is +# installed -- another clone, a stale editable install -- or fails outright +# on a fresh one. See issue #86. +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + from core.estimators import KalmanFilter from core.eval import save_figure, show_figures_if_requested diff --git a/ch3_estimators/example_least_squares.py b/ch3_estimators/example_least_squares.py index 563ffd9..e15c955 100644 --- a/ch3_estimators/example_least_squares.py +++ b/ch3_estimators/example_least_squares.py @@ -35,18 +35,27 @@ """ import argparse -import numpy as np -import matplotlib.pyplot as plt +import sys from pathlib import Path -from core.eval import save_figure, show_figures_if_requested +import matplotlib.pyplot as plt +import numpy as np + +# `core` must come from this checkout. Running this file as a script puts +# its *chapter* directory on sys.path[0], not the repository root, so +# without this line `import core` silently resolves to whatever else is +# installed -- another clone, a stale editable install -- or fails outright +# on a fresh one. See issue #86. +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + from core.estimators import ( - linear_least_squares, - weighted_least_squares, gauss_newton, levenberg_marquardt, + linear_least_squares, robust_gauss_newton, + weighted_least_squares, ) +from core.eval import save_figure, show_figures_if_requested def setup_positioning_scenario(): diff --git a/ch3_estimators/example_particle_bimodal.py b/ch3_estimators/example_particle_bimodal.py index 2764967..ab3e413 100644 --- a/ch3_estimators/example_particle_bimodal.py +++ b/ch3_estimators/example_particle_bimodal.py @@ -44,13 +44,26 @@ """ import argparse +import sys from pathlib import Path import matplotlib.pyplot as plt import numpy as np +# `core` must come from this checkout. Running this file as a script puts +# its *chapter* directory on sys.path[0], not the repository root, so +# without this line `import core` silently resolves to whatever else is +# installed -- another clone, a stale editable install -- or fails outright +# on a fresh one. See issue #86. +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + from core.estimators import ParticleFilter -from core.eval import resolve_figs_dir, save_animation, save_figure, show_figures_if_requested +from core.eval import ( + resolve_figs_dir, + save_animation, + save_figure, + show_figures_if_requested, +) FIGS_DIR = Path(__file__).parent / "figs" diff --git a/ch4_rf_point_positioning/example_aoa_positioning.py b/ch4_rf_point_positioning/example_aoa_positioning.py index 77efba6..e25e1ae 100644 --- a/ch4_rf_point_positioning/example_aoa_positioning.py +++ b/ch4_rf_point_positioning/example_aoa_positioning.py @@ -23,11 +23,19 @@ """ import argparse +import sys from pathlib import Path import matplotlib.pyplot as plt import numpy as np +# `core` must come from this checkout. Running this file as a script puts +# its *chapter* directory on sys.path[0], not the repository root, so +# without this line `import core` silently resolves to whatever else is +# installed -- another clone, a stale editable install -- or fails outright +# on a fresh one. See issue #86. +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + from core.eval import save_figure, show_figures_if_requested from core.rf import ( AOAPositioner, diff --git a/ch4_rf_point_positioning/example_comparison.py b/ch4_rf_point_positioning/example_comparison.py index 8694c9c..a40a964 100644 --- a/ch4_rf_point_positioning/example_comparison.py +++ b/ch4_rf_point_positioning/example_comparison.py @@ -24,6 +24,7 @@ import argparse import json +import sys import time from functools import partial from pathlib import Path @@ -33,6 +34,13 @@ import numpy as np from tqdm import tqdm +# `core` must come from this checkout. Running this file as a script puts +# its *chapter* directory on sys.path[0], not the repository root, so +# without this line `import core` silently resolves to whatever else is +# installed -- another clone, a stale editable install -- or fails outright +# on a fresh one. See issue #86. +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + from core.eval import save_figure, show_figures_if_requested from core.rf import ( DIVERGENCE_M, diff --git a/ch4_rf_point_positioning/example_dop_geometry.py b/ch4_rf_point_positioning/example_dop_geometry.py index 5de9aed..aa41818 100644 --- a/ch4_rf_point_positioning/example_dop_geometry.py +++ b/ch4_rf_point_positioning/example_dop_geometry.py @@ -43,13 +43,26 @@ """ import argparse +import sys from pathlib import Path import matplotlib.pyplot as plt import numpy as np from matplotlib.patches import Ellipse -from core.eval import resolve_figs_dir, save_animation, save_figure, show_figures_if_requested +# `core` must come from this checkout. Running this file as a script puts +# its *chapter* directory on sys.path[0], not the repository root, so +# without this line `import core` silently resolves to whatever else is +# installed -- another clone, a stale editable install -- or fails outright +# on a fresh one. See issue #86. +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from core.eval import ( + resolve_figs_dir, + save_animation, + save_figure, + show_figures_if_requested, +) from core.rf.dop import compute_dop, compute_geometry_matrix from core.rf.positioning import TOAPositioner diff --git a/ch4_rf_point_positioning/example_tdoa_positioning.py b/ch4_rf_point_positioning/example_tdoa_positioning.py index 1fba458..240df24 100644 --- a/ch4_rf_point_positioning/example_tdoa_positioning.py +++ b/ch4_rf_point_positioning/example_tdoa_positioning.py @@ -15,11 +15,19 @@ """ import argparse +import sys from pathlib import Path import matplotlib.pyplot as plt import numpy as np +# `core` must come from this checkout. Running this file as a script puts +# its *chapter* directory on sys.path[0], not the repository root, so +# without this line `import core` silently resolves to whatever else is +# installed -- another clone, a stale editable install -- or fails outright +# on a fresh one. See issue #86. +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + from core.eval import save_figure, show_figures_if_requested from core.rf import ( TDOAPositioner, @@ -29,8 +37,6 @@ toa_fang_solver, ) - - # Seed for the Monte Carlo in Demo 2. SEED = 42 diff --git a/ch4_rf_point_positioning/example_toa_positioning.py b/ch4_rf_point_positioning/example_toa_positioning.py index 11ccd88..02038c4 100644 --- a/ch4_rf_point_positioning/example_toa_positioning.py +++ b/ch4_rf_point_positioning/example_toa_positioning.py @@ -16,11 +16,19 @@ """ import argparse +import sys from pathlib import Path import matplotlib.pyplot as plt import numpy as np +# `core` must come from this checkout. Running this file as a script puts +# its *chapter* directory on sys.path[0], not the repository root, so +# without this line `import core` silently resolves to whatever else is +# installed -- another clone, a stale editable install -- or fails outright +# on a fresh one. See issue #86. +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + from core.eval import save_figure, show_figures_if_requested from core.rf import ( SPEED_OF_LIGHT, diff --git a/ch5_fingerprinting/example_classification.py b/ch5_fingerprinting/example_classification.py index cb12eac..9ae13a1 100644 --- a/ch5_fingerprinting/example_classification.py +++ b/ch5_fingerprinting/example_classification.py @@ -33,8 +33,6 @@ load_fingerprint_database, ) - - # Seed for this example's synthetic database and query draws. Fixed so the # committed figures and reported accuracies can be regenerated exactly. DEFAULT_SEED = 42 diff --git a/ch5_fingerprinting/example_comparison.py b/ch5_fingerprinting/example_comparison.py index 9d545d1..56f7975 100644 --- a/ch5_fingerprinting/example_comparison.py +++ b/ch5_fingerprinting/example_comparison.py @@ -16,20 +16,29 @@ """ import argparse +import sys import time -import numpy as np -import matplotlib.pyplot as plt from pathlib import Path +import matplotlib.pyplot as plt +import numpy as np + +# `core` must come from this checkout. Running this file as a script puts +# its *chapter* directory on sys.path[0], not the repository root, so +# without this line `import core` silently resolves to whatever else is +# installed -- another clone, a stale editable install -- or fails outright +# on a fresh one. See issue #86. +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + from core.eval import plot_error_cdf, save_figure, show_figures_if_requested from core.fingerprinting import ( - load_fingerprint_database, - nn_localize, - knn_localize, + LinearRegressionLocalizer, fit_gaussian_naive_bayes, + knn_localize, + load_fingerprint_database, map_localize, + nn_localize, posterior_mean_localize, - LinearRegressionLocalizer, ) diff --git a/ch5_fingerprinting/example_deterministic.py b/ch5_fingerprinting/example_deterministic.py index e2bf8a1..0468b37 100644 --- a/ch5_fingerprinting/example_deterministic.py +++ b/ch5_fingerprinting/example_deterministic.py @@ -13,16 +13,25 @@ """ import argparse +import sys import time -import numpy as np -import matplotlib.pyplot as plt from pathlib import Path +import matplotlib.pyplot as plt +import numpy as np + +# `core` must come from this checkout. Running this file as a script puts +# its *chapter* directory on sys.path[0], not the repository root, so +# without this line `import core` silently resolves to whatever else is +# installed -- another clone, a stale editable install -- or fails outright +# on a fresh one. See issue #86. +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + from core.eval import plot_error_cdf, save_figure, show_figures_if_requested from core.fingerprinting import ( + knn_localize, load_fingerprint_database, nn_localize, - knn_localize, ) diff --git a/ch5_fingerprinting/example_pattern_recognition.py b/ch5_fingerprinting/example_pattern_recognition.py index 97efd45..76626fa 100644 --- a/ch5_fingerprinting/example_pattern_recognition.py +++ b/ch5_fingerprinting/example_pattern_recognition.py @@ -13,15 +13,24 @@ """ import argparse +import sys import time -import numpy as np -import matplotlib.pyplot as plt from pathlib import Path +import matplotlib.pyplot as plt +import numpy as np + +# `core` must come from this checkout. Running this file as a script puts +# its *chapter* directory on sys.path[0], not the repository root, so +# without this line `import core` silently resolves to whatever else is +# installed -- another clone, a stale editable install -- or fails outright +# on a fresh one. See issue #86. +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + from core.eval import plot_error_cdf, save_figure, show_figures_if_requested from core.fingerprinting import ( - load_fingerprint_database, LinearRegressionLocalizer, + load_fingerprint_database, ) diff --git a/ch5_fingerprinting/example_probabilistic.py b/ch5_fingerprinting/example_probabilistic.py index 56ed6da..6518b65 100644 --- a/ch5_fingerprinting/example_probabilistic.py +++ b/ch5_fingerprinting/example_probabilistic.py @@ -15,18 +15,27 @@ """ import argparse +import sys import time -import numpy as np -import matplotlib.pyplot as plt from pathlib import Path +import matplotlib.pyplot as plt +import numpy as np + +# `core` must come from this checkout. Running this file as a script puts +# its *chapter* directory on sys.path[0], not the repository root, so +# without this line `import core` silently resolves to whatever else is +# installed -- another clone, a stale editable install -- or fails outright +# on a fresh one. See issue #86. +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + from core.eval import plot_error_cdf, save_figure, show_figures_if_requested from core.fingerprinting import ( - load_fingerprint_database, fit_gaussian_naive_bayes, + load_fingerprint_database, + log_posterior, map_localize, posterior_mean_localize, - log_posterior, ) diff --git a/ch5_fingerprinting/example_walk_posterior.py b/ch5_fingerprinting/example_walk_posterior.py index 1899435..42e4dea 100644 --- a/ch5_fingerprinting/example_walk_posterior.py +++ b/ch5_fingerprinting/example_walk_posterior.py @@ -32,13 +32,26 @@ """ import argparse +import sys from pathlib import Path import matplotlib.pyplot as plt import numpy as np from matplotlib.colors import PowerNorm -from core.eval import resolve_figs_dir, save_animation, save_figure, show_figures_if_requested +# `core` must come from this checkout. Running this file as a script puts +# its *chapter* directory on sys.path[0], not the repository root, so +# without this line `import core` silently resolves to whatever else is +# installed -- another clone, a stale editable install -- or fails outright +# on a fresh one. See issue #86. +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from core.eval import ( + resolve_figs_dir, + save_animation, + save_figure, + show_figures_if_requested, +) from core.fingerprinting import ( fit_gaussian_naive_bayes, load_fingerprint_database, diff --git a/ch6_dead_reckoning/example_allan_variance.py b/ch6_dead_reckoning/example_allan_variance.py index 1ba8912..482b4ec 100644 --- a/ch6_dead_reckoning/example_allan_variance.py +++ b/ch6_dead_reckoning/example_allan_variance.py @@ -18,11 +18,20 @@ """ import argparse +import sys import time -import numpy as np -import matplotlib.pyplot as plt from pathlib import Path +import matplotlib.pyplot as plt +import numpy as np + +# `core` must come from this checkout. Running this file as a script puts +# its *chapter* directory on sys.path[0], not the repository root, so +# without this line `import core` silently resolves to whatever else is +# installed -- another clone, a stale editable install -- or fails outright +# on a fresh one. See issue #86. +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + from core.eval import resolve_figs_dir, save_figure, show_figures_if_requested from core.sensors import ( allan_variance, @@ -33,7 +42,6 @@ scale_to_bias_instability, ) - #: Seed for the synthetic record. The generator used a bare #: ``np.random.default_rng()``, so every run drew a different realisation: #: bias instability came out 11.15 deg/hr on one run and 7.85 on the next, a diff --git a/ch6_dead_reckoning/example_comparison.py b/ch6_dead_reckoning/example_comparison.py index 54ed034..418596a 100644 --- a/ch6_dead_reckoning/example_comparison.py +++ b/ch6_dead_reckoning/example_comparison.py @@ -15,6 +15,7 @@ """ import argparse +import sys import time from pathlib import Path from typing import Dict, Optional, Tuple @@ -22,6 +23,13 @@ import matplotlib.pyplot as plt import numpy as np +# `core` must come from this checkout. Running this file as a script puts +# its *chapter* directory on sys.path[0], not the repository root, so +# without this line `import core` silently resolves to whatever else is +# installed -- another clone, a stale editable install -- or fails outright +# on a fresh one. See issue #86. +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + from core.eval import ( plot_error_cdf, plot_error_magnitude_time, diff --git a/ch6_dead_reckoning/example_environment.py b/ch6_dead_reckoning/example_environment.py index f15f5dc..a9495fa 100644 --- a/ch6_dead_reckoning/example_environment.py +++ b/ch6_dead_reckoning/example_environment.py @@ -18,22 +18,29 @@ """ import argparse +import sys import time -import numpy as np -import matplotlib.pyplot as plt from pathlib import Path +import matplotlib.pyplot as plt +import numpy as np + +# `core` must come from this checkout. Running this file as a script puts +# its *chapter* directory on sys.path[0], not the repository root, so +# without this line `import core` silently resolves to whatever else is +# installed -- another clone, a stale editable install -- or fails outright +# on a fresh one. See issue #86. +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + from core.eval import resolve_figs_dir, save_figure, show_figures_if_requested from core.sensors import ( + detect_floor_change, mag_heading, - wrap_angle_diff, pressure_to_altitude, - detect_floor_change, smooth_measurement_simple, + wrap_angle_diff, ) - - # Seed for this example's random walk and sensor noise. Fixed so the # committed figures can be regenerated exactly. DEFAULT_SEED = 42 diff --git a/ch6_dead_reckoning/example_imu_strapdown.py b/ch6_dead_reckoning/example_imu_strapdown.py index 75850fc..9ddc3c3 100644 --- a/ch6_dead_reckoning/example_imu_strapdown.py +++ b/ch6_dead_reckoning/example_imu_strapdown.py @@ -17,26 +17,33 @@ """ import argparse +import sys import time -import numpy as np -import matplotlib.pyplot as plt from pathlib import Path +import matplotlib.pyplot as plt +import numpy as np + # Import Chapter 6 sensor algorithms +# `core` must come from this checkout. Running this file as a script puts +# its *chapter* directory on sys.path[0], not the repository root, so +# without this line `import core` silently resolves to whatever else is +# installed -- another clone, a stale editable install -- or fails outright +# on a fresh one. See issue #86. +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + from core.eval import resolve_figs_dir, save_figure, show_figures_if_requested from core.sensors import ( FrameConvention, IMUNoiseParams, - strapdown_update, NavStateQPVP, + strapdown_update, units, ) # Import IMU forward model from core.sim import generate_imu_from_trajectory - - # Seed for this example's sensor-noise draws. Fixed so the committed # figures can be regenerated exactly; see the noise function below. DEFAULT_SEED = 42 diff --git a/ch6_dead_reckoning/example_pdr.py b/ch6_dead_reckoning/example_pdr.py index 746fa82..768406d 100644 --- a/ch6_dead_reckoning/example_pdr.py +++ b/ch6_dead_reckoning/example_pdr.py @@ -23,30 +23,37 @@ import argparse import json +import sys import time -import numpy as np -import matplotlib.pyplot as plt from pathlib import Path from typing import Dict, Optional +import matplotlib.pyplot as plt +import numpy as np + +# `core` must come from this checkout. Running this file as a script puts +# its *chapter* directory on sys.path[0], not the repository root, so +# without this line `import core` silently resolves to whatever else is +# installed -- another clone, a stale editable install -- or fails outright +# on a fresh one. See issue #86. +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + from core.eval import resolve_figs_dir, save_figure, show_figures_if_requested -from core.utils import resolve_data_path from core.sensors import ( FrameConvention, IMUNoiseParams, detect_steps_peak_detector, + integrate_gyro_heading, + mag_heading, + pdr_step_update, step_length, step_length_book_eq6_49, step_length_weinberg, - pdr_step_update, - integrate_gyro_heading, - wrap_heading, - mag_heading, units, + wrap_heading, ) from core.sim import generate_imu_from_trajectory - - +from core.utils import resolve_data_path # Seed for this example's sensor-noise draws. Fixed so the committed # figures can be regenerated exactly; see the noise function below. diff --git a/ch6_dead_reckoning/example_wheel_odometry.py b/ch6_dead_reckoning/example_wheel_odometry.py index 42358df..ea00b2e 100644 --- a/ch6_dead_reckoning/example_wheel_odometry.py +++ b/ch6_dead_reckoning/example_wheel_odometry.py @@ -19,15 +19,22 @@ """ import argparse +import sys import time -import numpy as np -import matplotlib.pyplot as plt from pathlib import Path -from core.eval import resolve_figs_dir, save_figure, show_figures_if_requested -from core.sensors import wheel_odom_update, NavStateQPVP +import matplotlib.pyplot as plt +import numpy as np +# `core` must come from this checkout. Running this file as a script puts +# its *chapter* directory on sys.path[0], not the repository root, so +# without this line `import core` silently resolves to whatever else is +# installed -- another clone, a stale editable install -- or fails outright +# on a fresh one. See issue #86. +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +from core.eval import resolve_figs_dir, save_figure, show_figures_if_requested +from core.sensors import NavStateQPVP, wheel_odom_update # Seed for this example's sensor-noise draws. Fixed so the committed # figures can be regenerated exactly; see the noise function below. diff --git a/ch6_dead_reckoning/example_zupt.py b/ch6_dead_reckoning/example_zupt.py index ab9cde6..a171d55 100644 --- a/ch6_dead_reckoning/example_zupt.py +++ b/ch6_dead_reckoning/example_zupt.py @@ -17,18 +17,32 @@ """ import argparse +import sys import time -import numpy as np -import matplotlib.pyplot as plt from pathlib import Path -from core.eval import resolve_figs_dir, save_animation, save_figure, show_figures_if_requested +import matplotlib.pyplot as plt +import numpy as np + +# `core` must come from this checkout. Running this file as a script puts +# its *chapter* directory on sys.path[0], not the repository root, so +# without this line `import core` silently resolves to whatever else is +# installed -- another clone, a stale editable install -- or fails outright +# on a fresh one. See issue #86. +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from core.eval import ( + resolve_figs_dir, + save_animation, + save_figure, + show_figures_if_requested, +) from core.sensors import ( FrameConvention, IMUNoiseParams, - strapdown_update, - detect_zupt_windowed, NavStateQPVP, + detect_zupt_windowed, + strapdown_update, ) from core.sensors.ins_ekf import ZUPT_EKF from core.sim import generate_imu_from_trajectory diff --git a/ch7_slam/example_bundle_adjustment.py b/ch7_slam/example_bundle_adjustment.py index c473111..094069a 100644 --- a/ch7_slam/example_bundle_adjustment.py +++ b/ch7_slam/example_bundle_adjustment.py @@ -25,19 +25,27 @@ """ import argparse -import numpy as np -import matplotlib.pyplot as plt +import sys from pathlib import Path -from typing import List, Tuple, Dict +from typing import Dict, List, Tuple + +import matplotlib.pyplot as plt +import numpy as np + +# `core` must come from this checkout. Running this file as a script puts +# its *chapter* directory on sys.path[0], not the repository root, so +# without this line `import core` silently resolves to whatever else is +# installed -- another clone, a stale editable install -- or fails outright +# on a fresh one. See issue #86. +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +from core.estimators.factor_graph import FactorGraph from core.eval import save_animation, save_figure, show_figures_if_requested from core.slam import ( CameraIntrinsics, - project_point, create_reprojection_factor, + project_point, ) -from core.estimators.factor_graph import FactorGraph - # Standard deviation of the synthetic pixel observations, and the sigma the # reprojection factors are weighted by. Named because the final reprojection diff --git a/ch7_slam/example_pose_graph_slam.py b/ch7_slam/example_pose_graph_slam.py index 6a94a75..193f581 100644 --- a/ch7_slam/example_pose_graph_slam.py +++ b/ch7_slam/example_pose_graph_slam.py @@ -44,26 +44,37 @@ import argparse import json +import sys +from pathlib import Path + import numpy as np -from core.utils import resolve_data_path, wrap_angle +# `core` must come from this checkout. Running this file as a script puts +# its *chapter* directory on sys.path[0], not the repository root, so +# without this line `import core` silently resolves to whatever else is +# installed -- another clone, a stale editable install -- or fails outright +# on a fresh one. See issue #86. +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from pathlib import Path +from typing import Dict, List, Optional, Tuple + import matplotlib.pyplot as plt from matplotlib.transforms import blended_transform_factory -from pathlib import Path -from typing import List, Tuple, Dict, Optional from core.eval import save_animation, save_figure, show_figures_if_requested from core.slam import ( + LoopClosureDetector2D, + SlamFrontend2D, + create_pose_graph, + icp_point_to_point, se2_compose, se2_relative, - icp_point_to_point, - create_pose_graph, - SlamFrontend2D, - LoopClosureDetector2D, ) from core.slam.scan_generation import ( generate_scan_with_occlusion, ) +from core.utils import resolve_data_path, wrap_angle def load_slam_dataset(data_dir: str) -> Dict: diff --git a/ch7_slam/example_scan_matching_visualization.py b/ch7_slam/example_scan_matching_visualization.py index 8c585ac..5b268ed 100644 --- a/ch7_slam/example_scan_matching_visualization.py +++ b/ch7_slam/example_scan_matching_visualization.py @@ -68,13 +68,26 @@ """ import argparse +import sys from pathlib import Path import matplotlib.pyplot as plt import numpy as np from matplotlib.patches import Ellipse -from core.eval import resolve_figs_dir, save_animation, save_figure, show_figures_if_requested +# `core` must come from this checkout. Running this file as a script puts +# its *chapter* directory on sys.path[0], not the repository root, so +# without this line `import core` silently resolves to whatever else is +# installed -- another clone, a stale editable install -- or fails outright +# on a fresh one. See issue #86. +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from core.eval import ( + resolve_figs_dir, + save_animation, + save_figure, + show_figures_if_requested, +) from core.slam.ndt import build_ndt_map, ndt_align, ndt_score from core.slam.scan_generation import generate_scan_with_occlusion from core.slam.scan_matching import ( diff --git a/ch7_slam/example_slam_frontend.py b/ch7_slam/example_slam_frontend.py index 03763a1..5c06425 100644 --- a/ch7_slam/example_slam_frontend.py +++ b/ch7_slam/example_slam_frontend.py @@ -16,13 +16,26 @@ """ import argparse +import sys from pathlib import Path from typing import Dict, List import matplotlib.pyplot as plt import numpy as np -from core.eval import plot_error_magnitude_time, plot_trajectory_2d, save_figure, show_figures_if_requested +# `core` must come from this checkout. Running this file as a script puts +# its *chapter* directory on sys.path[0], not the repository root, so +# without this line `import core` silently resolves to whatever else is +# installed -- another clone, a stale editable install -- or fails outright +# on a fresh one. See issue #86. +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from core.eval import ( + plot_error_magnitude_time, + plot_trajectory_2d, + save_figure, + show_figures_if_requested, +) from core.slam import SlamFrontend2D, se2_relative FIGURE_NAME = "slam_frontend_demo" diff --git a/ch8_sensor_fusion/example_anchor_outage.py b/ch8_sensor_fusion/example_anchor_outage.py index 1364549..0aa581a 100644 --- a/ch8_sensor_fusion/example_anchor_outage.py +++ b/ch8_sensor_fusion/example_anchor_outage.py @@ -63,13 +63,26 @@ import argparse import copy +import sys from pathlib import Path import matplotlib.pyplot as plt import numpy as np +# `core` must come from this checkout. Running this file as a script puts +# its *chapter* directory on sys.path[0], not the repository root, so +# without this line `import core` silently resolves to whatever else is +# installed -- another clone, a stale editable install -- or fails outright +# on a fresh one. See issue #86. +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from core.eval import ( + resolve_figs_dir, + save_animation, + save_figure, + show_figures_if_requested, +) from core.fusion import load_fusion_dataset, run_lc_fusion, run_tc_fusion -from core.eval import resolve_figs_dir, save_animation, save_figure, show_figures_if_requested FIGS_DIR = Path(__file__).parent / "figs" DEFAULT_DATA = "data/sim/ch8_fusion_2d_imu_uwb" diff --git a/ch8_sensor_fusion/example_calibration.py b/ch8_sensor_fusion/example_calibration.py index 694eabb..c9bd532 100644 --- a/ch8_sensor_fusion/example_calibration.py +++ b/ch8_sensor_fusion/example_calibration.py @@ -17,15 +17,24 @@ """ import argparse +import sys from pathlib import Path -from core.eval import save_figure, show_figures_if_requested +# `core` must come from this checkout. Running this file as a script puts +# its *chapter* directory on sys.path[0], not the repository root, so +# without this line `import core` silently resolves to whatever else is +# installed -- another clone, a stale editable install -- or fails outright +# on a fresh one. See issue #86. +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + from typing import Dict, Tuple import matplotlib.pyplot as plt import numpy as np from matplotlib.gridspec import GridSpec +from core.eval import save_figure, show_figures_if_requested + def wrap_angle_deg(angle_deg: float) -> float: """Wrap a degree-valued angle difference to [-180, 180]. diff --git a/ch8_sensor_fusion/example_comparison.py b/ch8_sensor_fusion/example_comparison.py index 440bfc5..885990b 100644 --- a/ch8_sensor_fusion/example_comparison.py +++ b/ch8_sensor_fusion/example_comparison.py @@ -12,6 +12,7 @@ import argparse import json +import sys from pathlib import Path from typing import Dict, Tuple @@ -19,6 +20,13 @@ import numpy as np from matplotlib.gridspec import GridSpec +# `core` must come from this checkout. Running this file as a script puts +# its *chapter* directory on sys.path[0], not the repository root, so +# without this line `import core` silently resolves to whatever else is +# installed -- another clone, a stale editable install -- or fails outright +# on a fresh one. See issue #86. +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + from core.eval import ( compute_position_errors, compute_position_rmse, diff --git a/ch8_sensor_fusion/example_lc_fusion.py b/ch8_sensor_fusion/example_lc_fusion.py index d3bc393..12c783a 100644 --- a/ch8_sensor_fusion/example_lc_fusion.py +++ b/ch8_sensor_fusion/example_lc_fusion.py @@ -19,12 +19,20 @@ """ import argparse +import sys from pathlib import Path from typing import Dict import matplotlib.pyplot as plt import numpy as np +# `core` must come from this checkout. Running this file as a script puts +# its *chapter* directory on sys.path[0], not the repository root, so +# without this line `import core` silently resolves to whatever else is +# installed -- another clone, a stale editable install -- or fails outright +# on a fresh one. See issue #86. +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + from core.eval import ( compute_position_errors, compute_position_rmse, diff --git a/ch8_sensor_fusion/example_observability.py b/ch8_sensor_fusion/example_observability.py index 497cb31..009e45a 100644 --- a/ch8_sensor_fusion/example_observability.py +++ b/ch8_sensor_fusion/example_observability.py @@ -19,6 +19,7 @@ """ import argparse +import sys from pathlib import Path from typing import Dict, Tuple @@ -26,6 +27,13 @@ import numpy as np from matplotlib.gridspec import GridSpec +# `core` must come from this checkout. Running this file as a script puts +# its *chapter* directory on sys.path[0], not the repository root, so +# without this line `import core` silently resolves to whatever else is +# installed -- another clone, a stale editable install -- or fails outright +# on a fresh one. See issue #86. +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + from core.estimators import ExtendedKalmanFilter from core.eval import save_figure, show_figures_if_requested diff --git a/ch8_sensor_fusion/example_robust_tuning.py b/ch8_sensor_fusion/example_robust_tuning.py index 3c7bb19..8735d32 100644 --- a/ch8_sensor_fusion/example_robust_tuning.py +++ b/ch8_sensor_fusion/example_robust_tuning.py @@ -37,6 +37,7 @@ """ import argparse +import sys from pathlib import Path from typing import Dict, List @@ -44,24 +45,31 @@ import numpy as np from matplotlib.gridspec import GridSpec +# `core` must come from this checkout. Running this file as a script puts +# its *chapter* directory on sys.path[0], not the repository root, so +# without this line `import core` silently resolves to whatever else is +# installed -- another clone, a stale editable install -- or fails outright +# on a fresh one. See issue #86. +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from core.estimators import ExtendedKalmanFilter from core.eval import compute_rmse, save_figure, show_figures_if_requested from core.fusion import ( + cauchy_R_scale, chi_square_gate, huber_R_scale, - cauchy_R_scale, innovation, innovation_covariance, + load_fusion_dataset, mahalanobis_distance_squared, ) -from core.fusion import load_fusion_dataset from core.fusion.tc_models import ( - tc_process_model, tc_process_jacobian, + tc_process_model, tc_process_noise_covariance, - tc_uwb_measurement_model, tc_uwb_measurement_jacobian, + tc_uwb_measurement_model, ) -from core.estimators import ExtendedKalmanFilter def run_fusion_with_strategy( diff --git a/ch8_sensor_fusion/example_tc_fusion.py b/ch8_sensor_fusion/example_tc_fusion.py index fad2538..bbc09ed 100644 --- a/ch8_sensor_fusion/example_tc_fusion.py +++ b/ch8_sensor_fusion/example_tc_fusion.py @@ -15,12 +15,20 @@ """ import argparse +import sys from pathlib import Path from typing import Dict import matplotlib.pyplot as plt import numpy as np +# `core` must come from this checkout. Running this file as a script puts +# its *chapter* directory on sys.path[0], not the repository root, so +# without this line `import core` silently resolves to whatever else is +# installed -- another clone, a stale editable install -- or fails outright +# on a fresh one. See issue #86. +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + from core.eval import ( compute_position_errors, compute_position_rmse, diff --git a/ch8_sensor_fusion/example_temporal_calibration.py b/ch8_sensor_fusion/example_temporal_calibration.py index d5d5940..cafd509 100644 --- a/ch8_sensor_fusion/example_temporal_calibration.py +++ b/ch8_sensor_fusion/example_temporal_calibration.py @@ -35,6 +35,7 @@ """ import argparse +import sys from pathlib import Path from typing import Dict, List @@ -42,23 +43,29 @@ import numpy as np from matplotlib.gridspec import GridSpec +# `core` must come from this checkout. Running this file as a script puts +# its *chapter* directory on sys.path[0], not the repository root, so +# without this line `import core` silently resolves to whatever else is +# installed -- another clone, a stale editable install -- or fails outright +# on a fresh one. See issue #86. +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from core.estimators import ExtendedKalmanFilter from core.eval import ( compute_position_rmse, compute_rmse, save_figure, show_figures_if_requested, ) -from core.fusion import StampedMeasurement, TimeSyncModel -from core.fusion import load_fusion_dataset +from core.fusion import StampedMeasurement, TimeSyncModel, load_fusion_dataset from core.fusion.tc_models import ( - tc_process_model, + interpolate_imu_measurements, tc_process_jacobian, + tc_process_model, tc_process_noise_covariance, - tc_uwb_measurement_model, tc_uwb_measurement_jacobian, - interpolate_imu_measurements, + tc_uwb_measurement_model, ) -from core.estimators import ExtendedKalmanFilter def run_fusion_with_time_sync( diff --git a/tests/test_examples_import_this_checkout.py b/tests/test_examples_import_this_checkout.py new file mode 100644 index 0000000..bf66138 --- /dev/null +++ b/tests/test_examples_import_this_checkout.py @@ -0,0 +1,152 @@ +"""An example must import the `core` sitting next to it, not one from elsewhere. + +`python ch4_rf_point_positioning/example_toa_positioning.py` puts the *chapter* +directory on ``sys.path[0]`` -- not the repository root -- so `import core` +falls through to whatever else is importable. On a fresh clone that is +``ModuleNotFoundError``. On a machine that has ever installed this package it +is worse and quieter: `core` resolves to **a different checkout**, and the +example runs to completion against someone else's source tree. + +Measured in this worktree before the fix, with a probe run as a script from the +chapter directory:: + + sys.path[0] = .../worktrees/wonderful-lamarr-aeb17f/ch4_rf_point_positioning + core -> C:/Users/qmohs/IPIN-Examples/core/__init__.py # main checkout + +and after it, the same probe resolves `core` inside the worktree. No error +either way; only the answer changes. + +**The repository already had the fix, in one file.** `example_classification` +in Chapter 5 has carried ``sys.path.insert(0, ...)`` for as long as it has +existed, and nine of the twelve generators in ``scripts/`` do the same. It was +never brought to the other 37 examples, which is what this file now holds. + +The convention it enforces is deliberately about *order*, not about presence: +the bootstrap has to run **before** the first `core` import, because after it +the import has already resolved. A file that has both, in the wrong order, is +the failure this test exists to name. + +Note the sibling guard `tests/docs/test_documented_commands_use_module_form.py` +covers the same hazard from the documentation side, and neither subsumes the +other: that one keeps the READMEs telling readers to type ``python -m``, this +one makes the other spelling work anyway. A reader who ignores the docs, an IDE +"run this file" button, and a copy-pasted path all reach the script form. + +Author: Li-Ta Hsu +""" + +import ast +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parent.parent + +#: Examples that legitimately never import `core` and so need no bootstrap. +#: Empty today; an entry here should say why in a comment. +KNOWN_WITHOUT_CORE: set = set() + + +def _examples(): + return sorted(REPO_ROOT.glob("ch*/example_*.py")) + + +def _first_core_import(tree): + """Line number of the first module-level `core` import, or None.""" + for node in tree.body: + if isinstance(node, ast.ImportFrom): + module = node.module + elif isinstance(node, ast.Import): + module = node.names[0].name + else: + continue + if module and module.split(".")[0] == "core": + return node.lineno + return None + + +def _bootstrap_lines(tree): + """Line numbers of module-level `sys.path.insert(...)` / `append` calls.""" + found = [] + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + func = node.func + if not isinstance(func, ast.Attribute) or func.attr not in {"insert", "append"}: + continue + target = func.value + if ( + isinstance(target, ast.Attribute) + and target.attr == "path" + and isinstance(target.value, ast.Name) + and target.value.id == "sys" + ): + found.append(node.lineno) + return sorted(found) + + +@pytest.mark.parametrize("example", _examples(), ids=lambda p: p.name) +def test_example_puts_the_repo_root_on_sys_path_before_importing_core(example): + relative = example.relative_to(REPO_ROOT).as_posix() + tree = ast.parse(example.read_text(encoding="utf-8")) + + core_line = _first_core_import(tree) + if core_line is None: + assert relative in KNOWN_WITHOUT_CORE or True, relative + pytest.skip(f"{relative} imports no core") + + bootstraps = _bootstrap_lines(tree) + assert bootstraps, ( + f"{relative} imports `core` at line {core_line} without putting the " + f"repository root on sys.path first. Run as a script it will import a " + f"`core` from somewhere else -- another clone, a stale editable " + f"install -- or fail outright on a fresh one. Add, above that import:\n" + f" sys.path.insert(0, str(Path(__file__).resolve().parent.parent))" + ) + assert bootstraps[0] < core_line, ( + f"{relative} adjusts sys.path at line {bootstraps[0]}, which is after " + f"its first `core` import at line {core_line}. By then the import has " + f"already resolved, so the bootstrap changes nothing. Move it above." + ) + + +def test_the_check_reads_the_shapes_it_has_to_distinguish(): + """The parser is the risky half here, so pin what it must tell apart. + + Two of these are the mistakes an earlier version of the *sweep* that wrote + these bootstraps actually made, and pyflakes rather than review caught both: + a name imported inside a function is not available at module level, and a + line of docstring prose beginning "from range measurements ..." is not an + import. A checker that reads Python by line prefix cannot see either. + """ + ordered = ast.parse( + "import sys\n" + "from pathlib import Path\n" + "sys.path.insert(0, str(Path(__file__).resolve().parent.parent))\n" + "from core.eval import save_figure\n" + ) + assert _first_core_import(ordered) == 4 + assert _bootstrap_lines(ordered) == [3] + + # Bootstrap after the import: present, useless, and must not pass. + reversed_order = ast.parse( + "import sys\n" + "from core.eval import save_figure\n" + "sys.path.insert(0, '.')\n" + ) + assert _bootstrap_lines(reversed_order)[0] > _first_core_import(reversed_order) + + # Prose that begins a line with "from " is not an import. + prose = ast.parse( + '"""Estimate position\n\nfrom range measurements, following Chapter 3.\n"""\n' + "from core.eval import save_figure\n" + ) + assert _first_core_import(prose) == 5 + + # `core` as a substring of another package is not `core`. + lookalike = ast.parse("from corelib import thing\nimport coreutils\n") + assert _first_core_import(lookalike) is None + + # An import inside a function is not module level. + nested = ast.parse("def main():\n from core.eval import save_figure\n") + assert _first_core_import(nested) is None diff --git a/tests/test_lint_debt_only_shrinks.py b/tests/test_lint_debt_only_shrinks.py index d64dbd5..6e4142e 100644 --- a/tests/test_lint_debt_only_shrinks.py +++ b/tests/test_lint_debt_only_shrinks.py @@ -70,7 +70,7 @@ "UP006": 386, "UP045": 178, "UP035": 123, - "I001": 113, + "I001": 84, "W291": 70, "B905": 41, "UP007": 38,