smelt-cli 0.1.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.
- smelt/__init__.py +6 -0
- smelt/__main__.py +5 -0
- smelt/analysis/__init__.py +27 -0
- smelt/analysis/context.py +106 -0
- smelt/analysis/files.py +198 -0
- smelt/analysis/graphs.py +77 -0
- smelt/analysis/imports.py +303 -0
- smelt/analysis/parsing.py +110 -0
- smelt/analysis/roles.py +122 -0
- smelt/analysis/syntax.py +246 -0
- smelt/analysis/types.py +181 -0
- smelt/cli/__init__.py +3 -0
- smelt/cli/app.py +140 -0
- smelt/cli/commands/__init__.py +18 -0
- smelt/cli/commands/adopt.py +116 -0
- smelt/cli/commands/check.py +80 -0
- smelt/cli/commands/discover.py +45 -0
- smelt/cli/commands/info.py +107 -0
- smelt/cli/commands/verify.py +60 -0
- smelt/cli/support.py +64 -0
- smelt/config/__init__.py +24 -0
- smelt/config/discovery.py +49 -0
- smelt/config/errors.py +51 -0
- smelt/config/loader.py +182 -0
- smelt/config/models.py +452 -0
- smelt/config/patterns.py +47 -0
- smelt/config/schema.py +23 -0
- smelt/config/validation.py +193 -0
- smelt/diagnostics/__init__.py +0 -0
- smelt/diagnostics/debt.py +119 -0
- smelt/diagnostics/dedupe.py +44 -0
- smelt/diagnostics/render/__init__.py +0 -0
- smelt/diagnostics/render/machine.py +137 -0
- smelt/diagnostics/render/text.py +197 -0
- smelt/diagnostics/report.py +61 -0
- smelt/diagnostics/suppressions.py +80 -0
- smelt/diagnostics/violation.py +109 -0
- smelt/docs.py +97 -0
- smelt/engine/__init__.py +0 -0
- smelt/engine/architecture_map.py +125 -0
- smelt/engine/briefing.py +364 -0
- smelt/engine/changes.py +81 -0
- smelt/engine/check.py +393 -0
- smelt/engine/inference.py +457 -0
- smelt/engine/verify.py +77 -0
- smelt/model/__init__.py +9 -0
- smelt/model/architecture.py +237 -0
- smelt/py.typed +0 -0
- smelt/rules/__init__.py +0 -0
- smelt/rules/base.py +142 -0
- smelt/rules/code/__init__.py +0 -0
- smelt/rules/code/common.py +119 -0
- smelt/rules/code/construction.py +172 -0
- smelt/rules/code/container.py +104 -0
- smelt/rules/code/inheritance.py +99 -0
- smelt/rules/code/roles.py +100 -0
- smelt/rules/code/self_reference.py +104 -0
- smelt/rules/common.py +127 -0
- smelt/rules/dependencies/__init__.py +0 -0
- smelt/rules/dependencies/composition_root.py +123 -0
- smelt/rules/dependencies/cycles.py +207 -0
- smelt/rules/dependencies/features.py +132 -0
- smelt/rules/dependencies/layers.py +220 -0
- smelt/rules/dependencies/third_party.py +107 -0
- smelt/rules/meta.py +72 -0
- smelt/rules/registry.py +135 -0
- smelt/rules/structure/__init__.py +0 -0
- smelt/rules/structure/layout.py +213 -0
- smelt/rules/structure/naming.py +63 -0
- smelt/rules/structure/roles.py +63 -0
- smelt/rules/testing/__init__.py +0 -0
- smelt/rules/testing/api.py +115 -0
- smelt/rules/testing/bloat.py +84 -0
- smelt/rules/testing/common.py +238 -0
- smelt/rules/testing/location.py +253 -0
- smelt/rules/testing/mocks.py +294 -0
- smelt/rules/testing/patching.py +152 -0
- smelt_cli-0.1.0.dist-info/METADATA +199 -0
- smelt_cli-0.1.0.dist-info/RECORD +81 -0
- smelt_cli-0.1.0.dist-info/WHEEL +4 -0
- smelt_cli-0.1.0.dist-info/entry_points.txt +3 -0
smelt/__init__.py
ADDED
smelt/__main__.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from smelt.analysis.context import AnalysisContext, ChangeSet, FileChange, Index
|
|
2
|
+
from smelt.analysis.files import FileIndex, SourceFile, TestFile
|
|
3
|
+
from smelt.analysis.imports import ImportDetail, ImportIndex, is_stdlib
|
|
4
|
+
from smelt.analysis.parsing import AnalysisError
|
|
5
|
+
from smelt.analysis.roles import RoleIndex
|
|
6
|
+
from smelt.analysis.syntax import ClassInfo, ModuleSyntax, SyntaxIndex
|
|
7
|
+
from smelt.analysis.types import PyrightTypes, TypeIndex
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"AnalysisContext",
|
|
11
|
+
"AnalysisError",
|
|
12
|
+
"ChangeSet",
|
|
13
|
+
"ClassInfo",
|
|
14
|
+
"FileChange",
|
|
15
|
+
"FileIndex",
|
|
16
|
+
"ImportDetail",
|
|
17
|
+
"ImportIndex",
|
|
18
|
+
"Index",
|
|
19
|
+
"ModuleSyntax",
|
|
20
|
+
"PyrightTypes",
|
|
21
|
+
"RoleIndex",
|
|
22
|
+
"SourceFile",
|
|
23
|
+
"SyntaxIndex",
|
|
24
|
+
"TestFile",
|
|
25
|
+
"TypeIndex",
|
|
26
|
+
"is_stdlib",
|
|
27
|
+
]
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from enum import StrEnum
|
|
5
|
+
from functools import cached_property
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from smelt.analysis.files import FileIndex
|
|
9
|
+
from smelt.analysis.imports import ImportIndex
|
|
10
|
+
from smelt.analysis.parsing import AstCache
|
|
11
|
+
from smelt.analysis.roles import RoleIndex
|
|
12
|
+
from smelt.analysis.syntax import SyntaxIndex
|
|
13
|
+
from smelt.analysis.types import PyrightTypes, TypeIndex
|
|
14
|
+
from smelt.model import ArchitectureModel
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from smelt.config.models import SmeltConfig
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Index(StrEnum):
|
|
23
|
+
FILES = "files"
|
|
24
|
+
IMPORTS = "imports"
|
|
25
|
+
SYNTAX = "syntax"
|
|
26
|
+
ROLES = "roles"
|
|
27
|
+
TYPES = "types"
|
|
28
|
+
CHANGES = "changes"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True, slots=True)
|
|
32
|
+
class FileChange:
|
|
33
|
+
path: str
|
|
34
|
+
added: int
|
|
35
|
+
deleted: int
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class ChangeSet:
|
|
40
|
+
"""Files changed against a git base; paths are relative to the project root."""
|
|
41
|
+
|
|
42
|
+
files: dict[str, FileChange] = field(default_factory=dict)
|
|
43
|
+
|
|
44
|
+
def __contains__(self, path: object) -> bool:
|
|
45
|
+
return path in self.files
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def paths(self) -> frozenset[str]:
|
|
49
|
+
return frozenset(self.files)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class AnalysisContext:
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
root: Path,
|
|
56
|
+
config: SmeltConfig,
|
|
57
|
+
*,
|
|
58
|
+
changes: ChangeSet | None = None,
|
|
59
|
+
types: TypeIndex | None = None,
|
|
60
|
+
) -> None:
|
|
61
|
+
self.root = root
|
|
62
|
+
self.config = config
|
|
63
|
+
self.changes = changes
|
|
64
|
+
self._types = types
|
|
65
|
+
|
|
66
|
+
@cached_property
|
|
67
|
+
def types(self) -> TypeIndex | None:
|
|
68
|
+
"""The configured type backend, or None when there is none to be had."""
|
|
69
|
+
if self._types is not None:
|
|
70
|
+
return self._types
|
|
71
|
+
if self.config.analysis.types == "pyright":
|
|
72
|
+
return PyrightTypes.discover(self.root, self.config)
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
@cached_property
|
|
76
|
+
def files(self) -> FileIndex:
|
|
77
|
+
return FileIndex.discover(self.root, self.config)
|
|
78
|
+
|
|
79
|
+
@cached_property
|
|
80
|
+
def asts(self) -> AstCache:
|
|
81
|
+
return AstCache(self.files)
|
|
82
|
+
|
|
83
|
+
@cached_property
|
|
84
|
+
def model(self) -> ArchitectureModel:
|
|
85
|
+
return ArchitectureModel.build(
|
|
86
|
+
self.config, self.files.sources, self.files.packages
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
@cached_property
|
|
90
|
+
def imports(self) -> ImportIndex:
|
|
91
|
+
return ImportIndex.build(self.files, self.asts)
|
|
92
|
+
|
|
93
|
+
@cached_property
|
|
94
|
+
def syntax(self) -> SyntaxIndex:
|
|
95
|
+
return SyntaxIndex(self.files, self.asts)
|
|
96
|
+
|
|
97
|
+
@cached_property
|
|
98
|
+
def roles(self) -> RoleIndex:
|
|
99
|
+
return RoleIndex(self.config, self.syntax, self.types)
|
|
100
|
+
|
|
101
|
+
def ensure(self, indexes: frozenset[Index]) -> None:
|
|
102
|
+
"""Build the requested indexes up front so failures surface before rules run."""
|
|
103
|
+
for index in sorted(indexes):
|
|
104
|
+
if index is Index.TYPES or index is Index.CHANGES:
|
|
105
|
+
continue
|
|
106
|
+
getattr(self, index.value)
|
smelt/analysis/files.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from functools import cached_property
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from smelt.config.patterns import path_matches
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from collections.abc import Iterable, Iterator
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from smelt.config.models import SmeltConfig
|
|
15
|
+
|
|
16
|
+
_SKIP_DIRS = frozenset({"__pycache__", ".git", ".venv", "venv", "node_modules"})
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, slots=True)
|
|
20
|
+
class SourceFile:
|
|
21
|
+
module: str
|
|
22
|
+
path: str # POSIX, relative to the project root
|
|
23
|
+
absolute: Path
|
|
24
|
+
is_package: bool # an ``__init__.py``
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True, slots=True)
|
|
28
|
+
class TestFile:
|
|
29
|
+
path: str
|
|
30
|
+
absolute: Path
|
|
31
|
+
test_root: str
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def name(self) -> str:
|
|
35
|
+
return self.path.rsplit("/", 1)[-1]
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def is_conftest(self) -> bool:
|
|
39
|
+
return self.name == "conftest.py"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(slots=True)
|
|
43
|
+
class PackageDir:
|
|
44
|
+
module: str
|
|
45
|
+
path: str
|
|
46
|
+
source_root: str
|
|
47
|
+
has_init: bool
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class FileIndex:
|
|
52
|
+
root: Path
|
|
53
|
+
config: SmeltConfig
|
|
54
|
+
sources: dict[str, SourceFile] = field(default_factory=dict)
|
|
55
|
+
packages: dict[str, PackageDir] = field(default_factory=dict)
|
|
56
|
+
tests: dict[str, TestFile] = field(default_factory=dict)
|
|
57
|
+
missing_root_packages: list[str] = field(default_factory=list)
|
|
58
|
+
_text_cache: dict[str, str] = field(default_factory=dict, repr=False)
|
|
59
|
+
|
|
60
|
+
@classmethod
|
|
61
|
+
def discover(cls, root: Path, config: SmeltConfig) -> FileIndex:
|
|
62
|
+
index = cls(root=root, config=config)
|
|
63
|
+
index._discover_sources()
|
|
64
|
+
index._discover_tests()
|
|
65
|
+
return index
|
|
66
|
+
|
|
67
|
+
def is_excluded(self, path: str) -> bool:
|
|
68
|
+
return any(
|
|
69
|
+
path_matches(pattern, path) for pattern in self.config.project.exclude
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
def relative(self, path: Path) -> str:
|
|
73
|
+
try:
|
|
74
|
+
return path.resolve().relative_to(self.root.resolve()).as_posix()
|
|
75
|
+
except ValueError:
|
|
76
|
+
return path.as_posix()
|
|
77
|
+
|
|
78
|
+
def _discover_sources(self) -> None:
|
|
79
|
+
project = self.config.project
|
|
80
|
+
for package in project.root_packages:
|
|
81
|
+
found = False
|
|
82
|
+
for source_root in project.source_roots:
|
|
83
|
+
directory = self.root / source_root / package
|
|
84
|
+
if directory.is_dir():
|
|
85
|
+
found = True
|
|
86
|
+
self._walk_package(directory, package, source_root)
|
|
87
|
+
break
|
|
88
|
+
if not found:
|
|
89
|
+
self.missing_root_packages.append(package)
|
|
90
|
+
|
|
91
|
+
def _walk_package(self, directory: Path, module: str, source_root: str) -> None:
|
|
92
|
+
rel_dir = self.relative(directory)
|
|
93
|
+
if self.is_excluded(rel_dir):
|
|
94
|
+
return
|
|
95
|
+
init = directory / "__init__.py"
|
|
96
|
+
self.packages[module] = PackageDir(module, rel_dir, source_root, init.is_file())
|
|
97
|
+
for entry in sorted(directory.iterdir(), key=lambda p: p.name):
|
|
98
|
+
if entry.is_dir():
|
|
99
|
+
if entry.name in _SKIP_DIRS or not entry.name.isidentifier():
|
|
100
|
+
continue
|
|
101
|
+
if _contains_python(entry):
|
|
102
|
+
self._walk_package(entry, f"{module}.{entry.name}", source_root)
|
|
103
|
+
elif entry.suffix == ".py" and entry.is_file():
|
|
104
|
+
rel = self.relative(entry)
|
|
105
|
+
if self.is_excluded(rel):
|
|
106
|
+
continue
|
|
107
|
+
if entry.name == "__init__.py":
|
|
108
|
+
self.sources[module] = SourceFile(
|
|
109
|
+
module, rel, entry, is_package=True
|
|
110
|
+
)
|
|
111
|
+
elif entry.stem.isidentifier():
|
|
112
|
+
name = f"{module}.{entry.stem}"
|
|
113
|
+
self.sources[name] = SourceFile(name, rel, entry, is_package=False)
|
|
114
|
+
|
|
115
|
+
def _discover_tests(self) -> None:
|
|
116
|
+
source_paths = {source.path for source in self.sources.values()}
|
|
117
|
+
for test_root in self.config.project.test_roots:
|
|
118
|
+
directory = self.root / test_root
|
|
119
|
+
if not directory.is_dir():
|
|
120
|
+
continue
|
|
121
|
+
for path in sorted(_python_files(directory)):
|
|
122
|
+
rel = self.relative(path)
|
|
123
|
+
if rel in source_paths or self.is_excluded(rel):
|
|
124
|
+
continue
|
|
125
|
+
name = path.name
|
|
126
|
+
if (
|
|
127
|
+
name == "conftest.py"
|
|
128
|
+
or name.startswith("test_")
|
|
129
|
+
or path.stem.endswith("_test")
|
|
130
|
+
):
|
|
131
|
+
self.tests[rel] = TestFile(rel, path, test_root.strip("/"))
|
|
132
|
+
|
|
133
|
+
@cached_property
|
|
134
|
+
def by_path(self) -> dict[str, SourceFile]:
|
|
135
|
+
return {source.path: source for source in self.sources.values()}
|
|
136
|
+
|
|
137
|
+
def source_for_path(self, path: str) -> SourceFile | None:
|
|
138
|
+
return self.by_path.get(path)
|
|
139
|
+
|
|
140
|
+
def path_for_module(self, module: str) -> str | None:
|
|
141
|
+
source = self.sources.get(module)
|
|
142
|
+
if source is not None:
|
|
143
|
+
return source.path
|
|
144
|
+
package = self.packages.get(module)
|
|
145
|
+
return package.path if package else None
|
|
146
|
+
|
|
147
|
+
def read_text(self, path: str) -> str:
|
|
148
|
+
cached = self._text_cache.get(path)
|
|
149
|
+
if cached is None:
|
|
150
|
+
cached = (self.root / path).read_text(encoding="utf-8", errors="replace")
|
|
151
|
+
self._text_cache[path] = cached
|
|
152
|
+
return cached
|
|
153
|
+
|
|
154
|
+
def lines(self, path: str) -> list[str]:
|
|
155
|
+
return self.read_text(path).splitlines()
|
|
156
|
+
|
|
157
|
+
def line(self, path: str, number: int) -> str:
|
|
158
|
+
lines = self.lines(path)
|
|
159
|
+
return lines[number - 1] if 0 < number <= len(lines) else ""
|
|
160
|
+
|
|
161
|
+
def sha256(self, path: str) -> str:
|
|
162
|
+
return hashlib.sha256(self.read_text(path).encode()).hexdigest()
|
|
163
|
+
|
|
164
|
+
def size(self, path: str) -> int:
|
|
165
|
+
return len(self.read_text(path).encode())
|
|
166
|
+
|
|
167
|
+
def all_python_paths(self) -> Iterator[str]:
|
|
168
|
+
yield from (source.path for source in self.sources.values())
|
|
169
|
+
yield from self.tests
|
|
170
|
+
|
|
171
|
+
def source_root_dir(self, module: str) -> str | None:
|
|
172
|
+
root = module.split(".", maxsplit=1)[0]
|
|
173
|
+
package = self.packages.get(root)
|
|
174
|
+
return package.source_root if package else None
|
|
175
|
+
|
|
176
|
+
def module_to_path(self, module: str, *, package: bool = False) -> str:
|
|
177
|
+
"""The expected repo-relative path for ``module``, whether or not it exists."""
|
|
178
|
+
root = (
|
|
179
|
+
self.source_root_dir(module)
|
|
180
|
+
or (self.config.project.source_roots or ["."])[0]
|
|
181
|
+
)
|
|
182
|
+
parts = [p for p in root.strip("/").split("/") if p and p != "."]
|
|
183
|
+
parts.extend(module.split("."))
|
|
184
|
+
return "/".join(parts) + ("/" if package else ".py")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _contains_python(directory: Path) -> bool:
|
|
188
|
+
return any(True for _ in _python_files(directory))
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _python_files(directory: Path) -> Iterable[Path]:
|
|
192
|
+
for entry in directory.iterdir():
|
|
193
|
+
if entry.is_dir():
|
|
194
|
+
if entry.name in _SKIP_DIRS or entry.name.startswith("."):
|
|
195
|
+
continue
|
|
196
|
+
yield from _python_files(entry)
|
|
197
|
+
elif entry.suffix == ".py":
|
|
198
|
+
yield entry
|
smelt/analysis/graphs.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
from collections import deque
|
|
2
|
+
from collections.abc import Hashable, Mapping, Set
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def strongly_connected_components[T: Hashable](
|
|
6
|
+
adjacency: Mapping[T, Set[T]],
|
|
7
|
+
) -> list[set[T]]:
|
|
8
|
+
"""Tarjan's algorithm, iterative so deep graphs don't hit the recursion limit."""
|
|
9
|
+
index_of: dict[T, int] = {}
|
|
10
|
+
lowlink: dict[T, int] = {}
|
|
11
|
+
on_stack: set[T] = set()
|
|
12
|
+
stack: list[T] = []
|
|
13
|
+
components: list[set[T]] = []
|
|
14
|
+
counter = 0
|
|
15
|
+
nodes = set(adjacency) | {n for targets in adjacency.values() for n in targets}
|
|
16
|
+
|
|
17
|
+
for start in sorted(nodes, key=str):
|
|
18
|
+
if start in index_of:
|
|
19
|
+
continue
|
|
20
|
+
work: list[tuple[T, list[T]]] = [
|
|
21
|
+
(start, sorted(adjacency.get(start, ()), key=str))
|
|
22
|
+
]
|
|
23
|
+
index_of[start] = lowlink[start] = counter
|
|
24
|
+
counter += 1
|
|
25
|
+
stack.append(start)
|
|
26
|
+
on_stack.add(start)
|
|
27
|
+
while work:
|
|
28
|
+
node, children = work[-1]
|
|
29
|
+
if children:
|
|
30
|
+
child = children.pop(0)
|
|
31
|
+
if child not in index_of:
|
|
32
|
+
index_of[child] = lowlink[child] = counter
|
|
33
|
+
counter += 1
|
|
34
|
+
stack.append(child)
|
|
35
|
+
on_stack.add(child)
|
|
36
|
+
work.append((child, sorted(adjacency.get(child, ()), key=str)))
|
|
37
|
+
elif child in on_stack:
|
|
38
|
+
lowlink[node] = min(lowlink[node], index_of[child])
|
|
39
|
+
continue
|
|
40
|
+
work.pop()
|
|
41
|
+
if work:
|
|
42
|
+
parent = work[-1][0]
|
|
43
|
+
lowlink[parent] = min(lowlink[parent], lowlink[node])
|
|
44
|
+
if lowlink[node] == index_of[node]:
|
|
45
|
+
component: set[T] = set()
|
|
46
|
+
while True:
|
|
47
|
+
member = stack.pop()
|
|
48
|
+
on_stack.discard(member)
|
|
49
|
+
component.add(member)
|
|
50
|
+
if member == node:
|
|
51
|
+
break
|
|
52
|
+
components.append(component)
|
|
53
|
+
return components
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def shortest_cycle[T: Hashable](
|
|
57
|
+
start: T, adjacency: Mapping[T, Set[T]], within: Set[T]
|
|
58
|
+
) -> list[T] | None:
|
|
59
|
+
"""Shortest path ``start -> ... -> start`` using only nodes in ``within``."""
|
|
60
|
+
previous: dict[T, T] = {}
|
|
61
|
+
queue: deque[T] = deque([start])
|
|
62
|
+
visited: set[T] = set()
|
|
63
|
+
while queue:
|
|
64
|
+
node = queue.popleft()
|
|
65
|
+
for child in sorted(adjacency.get(node, ()), key=str):
|
|
66
|
+
if child not in within:
|
|
67
|
+
continue
|
|
68
|
+
if child == start:
|
|
69
|
+
path = [node]
|
|
70
|
+
while path[-1] != start:
|
|
71
|
+
path.append(previous[path[-1]])
|
|
72
|
+
return [*reversed(path), start]
|
|
73
|
+
if child not in visited:
|
|
74
|
+
visited.add(child)
|
|
75
|
+
previous[child] = node
|
|
76
|
+
queue.append(child)
|
|
77
|
+
return None
|