|
1 | 1 | """Small conversions the asset system needs in more than one layer: canonical |
2 | | -stored-hash strings, UTC timestamps, tag normalization, and a SQL predicate for |
3 | | -path containment. That predicate is deliberately case-sensitive and |
4 | | -component-bounded, because callers use it to choose rows for hard deletion. |
| 2 | +stored-hash strings, UTC timestamps, tag normalization, and path containment |
| 3 | +checks. The SQL predicate is deliberately case-sensitive and component-bounded, |
| 4 | +because callers use it to choose rows for hard deletion; the Python matcher |
| 5 | +follows ``Path.is_relative_to`` instead, including its platform case rules. |
5 | 6 | """ |
6 | 7 |
|
| 8 | +import functools |
7 | 9 | import os |
| 10 | +from collections.abc import Callable, Iterable |
8 | 11 | from datetime import datetime, timezone |
9 | 12 |
|
10 | 13 | import sqlalchemy as sa |
@@ -42,6 +45,53 @@ def sql_path_under_prefix( |
42 | 45 | ) |
43 | 46 |
|
44 | 47 |
|
| 48 | +def path_prefix_matcher(prefixes: Iterable[str]) -> Callable[[str], bool]: |
| 49 | + """Return ``path -> Path(path).is_relative_to(<any prefix>)``, with the prefixes |
| 50 | + normalized once. |
| 51 | +
|
| 52 | + The startup prune tests every catalogued row against every owned prefix, and |
| 53 | + ``Path.is_relative_to`` walks the path's parents on each call, so a pathlib |
| 54 | + check there costs rows x prefixes x depth. A normcase'd, separator-bounded |
| 55 | + string prefix keeps its component bounds and platform case rules. |
| 56 | + """ |
| 57 | + # abspath keeps exactly two leading separators, and pathlib treats that "//" as an |
| 58 | + # anchor of its own: "//server/f" is not under "/". Such paths are only matched |
| 59 | + # against prefixes with the same anchor. |
| 60 | + double = os.sep * 2 |
| 61 | + exact: dict[bool, set[str]] = {False: set(), True: set()} |
| 62 | + stems: dict[bool, list[str]] = {False: [], True: []} |
| 63 | + for prefix in prefixes: |
| 64 | + base = os.path.normcase(os.path.abspath(prefix)) |
| 65 | + is_double = base.startswith(double) |
| 66 | + exact[is_double].add(base) |
| 67 | + stems[is_double].append(base if base.endswith(os.sep) else base + os.sep) |
| 68 | + stem_tuples = {key: tuple(value) for key, value in stems.items()} |
| 69 | + |
| 70 | + def matches(path: str) -> bool: |
| 71 | + candidate = os.path.normcase(os.path.abspath(path)) |
| 72 | + is_double = candidate.startswith(double) |
| 73 | + return candidate in exact[is_double] or candidate.startswith(stem_tuples[is_double]) |
| 74 | + |
| 75 | + return matches |
| 76 | + |
| 77 | + |
| 78 | +@functools.lru_cache(maxsize=None) |
| 79 | +def cached_prefix_matcher(prefixes: tuple[str, ...]) -> Callable[[str], bool]: |
| 80 | + """path_prefix_matcher, built once per distinct prefix tuple, for callers that check |
| 81 | + every scanned file against the same folders. |
| 82 | +
|
| 83 | + Keyed on the raw prefixes: normalizing them in the key would bring back the per-call |
| 84 | + cost this avoids. A folder-config change is just a new key. |
| 85 | +
|
| 86 | + Precondition: the prefixes are absolute. That is not checked. main.py and |
| 87 | + extra_model_paths make their folders absolute, but folder_paths' setters and |
| 88 | + add_model_folder_path store whatever they are given, so a custom node can register a |
| 89 | + relative one. A relative prefix is resolved against the working directory once, on |
| 90 | + first use, and that resolution is then frozen for as long as the key is unchanged. |
| 91 | + """ |
| 92 | + return path_prefix_matcher(prefixes) |
| 93 | + |
| 94 | + |
45 | 95 | def escape_sql_like_string(s: str, escape: str = "!") -> tuple[str, str]: |
46 | 96 | """Escapes %, _ and the escape char in a LIKE prefix. |
47 | 97 |
|
|
0 commit comments