hlsync 1.17.2__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.

Potentially problematic release.


This version of hlsync might be problematic. Click here for more details.

hlsync/progress.py ADDED
@@ -0,0 +1,57 @@
1
+ """Directory progress based on work discovered by the remote traversal."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ from pathlib import PurePosixPath
8
+ from typing import TextIO
9
+
10
+
11
+ class DirectoryReadProgress:
12
+ def __init__(self, output: TextIO) -> None:
13
+ self.output = output
14
+ self.terminal = output.isatty() and os.environ.get("TERM") != "dumb"
15
+ self.directory = "."
16
+ self.completed = 0
17
+ self.discovered = 1
18
+
19
+ def _draw(self) -> None:
20
+ text = (
21
+ f" {self.completed}/{self.discovered} discovered directories read"
22
+ f" · {self.directory}/"
23
+ )
24
+ if self.terminal:
25
+ width = max(1, shutil.get_terminal_size().columns - 1)
26
+ print(f"\r\033[2K{text[:width]}", end="", file=self.output, flush=True)
27
+ else:
28
+ print(text, file=self.output, flush=True)
29
+
30
+ def __enter__(self) -> DirectoryReadProgress:
31
+ return self
32
+
33
+ def reading(self, directory: PurePosixPath) -> None:
34
+ self.directory = directory.as_posix()
35
+ self._draw()
36
+
37
+ def counts(self, completed: int, discovered: int) -> None:
38
+ self.completed = completed
39
+ self.discovered = discovered
40
+ if self.terminal:
41
+ self._draw()
42
+
43
+ def message(self, message: str) -> None:
44
+ if self.terminal:
45
+ print("\r\033[2K", end="", file=self.output)
46
+ print(f" {message}", file=self.output, flush=True)
47
+ if self.terminal:
48
+ self._draw()
49
+
50
+ def __exit__(self, exc_type, exc_value, traceback) -> None:
51
+ if self.terminal:
52
+ print("\r\033[2K", end="", file=self.output, flush=True)
53
+ if exc_type is None:
54
+ print(
55
+ f" Read {self.completed}/{self.discovered} directories.",
56
+ file=self.output, flush=True,
57
+ )
hlsync/rules.py ADDED
@@ -0,0 +1,276 @@
1
+ from __future__ import annotations
2
+
3
+ import glob
4
+ from dataclasses import dataclass
5
+ from pathlib import Path, PurePosixPath
6
+ from typing import Any, Literal
7
+
8
+ from hlsync.gitignore import GitIgnores
9
+ from hlsync.selection import FileSelector, SelectionError
10
+
11
+ RuleAction = Literal["include", "exclude"]
12
+ RuleTarget = Literal["local", "remote"]
13
+
14
+
15
+ class RuleError(ValueError):
16
+ """Raised when an HLSync synchronization rule is unsafe or malformed."""
17
+
18
+
19
+ def _relative_base(profile_root: Path, current_directory: Path) -> PurePosixPath:
20
+ try:
21
+ relative = current_directory.relative_to(profile_root)
22
+ except ValueError:
23
+ return PurePosixPath()
24
+ return PurePosixPath(*relative.parts)
25
+
26
+
27
+ def expand_path_operands(
28
+ values: tuple[str, ...],
29
+ *,
30
+ profile_root: Path,
31
+ current_directory: Path,
32
+ ) -> tuple[str, ...]:
33
+ """Expand wildcard operands to the local paths they currently match."""
34
+ try:
35
+ current_directory.relative_to(profile_root)
36
+ except ValueError:
37
+ expansion_root = profile_root
38
+ else:
39
+ expansion_root = current_directory
40
+ expanded: list[str] = []
41
+ for value in values:
42
+ supplied = PurePosixPath(value)
43
+ if supplied.is_absolute() or ".." in supplied.parts:
44
+ raise RuleError(
45
+ "rule paths must be relative to the profile or current directory"
46
+ )
47
+ if "*" not in value:
48
+ expanded.append(value)
49
+ continue
50
+ matches = glob.glob(
51
+ value,
52
+ root_dir=expansion_root,
53
+ recursive=True,
54
+ include_hidden=False,
55
+ )
56
+ if not matches:
57
+ raise RuleError(f"path expression '{value}' matched no local paths")
58
+ expanded.extend(PurePosixPath(match).as_posix() for match in sorted(matches))
59
+ return tuple(dict.fromkeys(expanded))
60
+
61
+
62
+ def patterns_from_operands(
63
+ values: tuple[str, ...],
64
+ *,
65
+ profile_root: Path,
66
+ current_directory: Path,
67
+ ) -> tuple[str, ...]:
68
+ """Translate CLI operands into profile-rooted HLSync patterns."""
69
+ base = _relative_base(profile_root, current_directory)
70
+ normalized: list[str] = []
71
+ for value in values:
72
+ if not isinstance(value, str) or not value.strip():
73
+ raise RuleError("rule patterns must be non-empty strings")
74
+ pattern = value.strip()
75
+ supplied = PurePosixPath(pattern)
76
+ if supplied.is_absolute() or ".." in supplied.parts:
77
+ raise RuleError(
78
+ "rule patterns must be relative to the profile or current directory"
79
+ )
80
+ rooted = base / supplied
81
+ local_path = profile_root.joinpath(*rooted.parts)
82
+ is_directory = pattern.endswith("/") or (
83
+ "*" not in pattern and local_path.is_dir() and not local_path.is_symlink()
84
+ )
85
+ if is_directory:
86
+ rooted = rooted / "**"
87
+ candidate = rooted.as_posix()
88
+ try:
89
+ candidate = FileSelector(candidate).pattern
90
+ except SelectionError as error:
91
+ raise RuleError(str(error)) from error
92
+ normalized.append(candidate)
93
+ return tuple(normalized)
94
+
95
+
96
+ def patterns_from_global_operands(
97
+ values: tuple[str, ...],
98
+ *,
99
+ trailing_slash_tree: bool = True,
100
+ ) -> tuple[str, ...]:
101
+ """Normalize reusable patterns rooted at every profile's local root."""
102
+ normalized: list[str] = []
103
+ for value in values:
104
+ if not isinstance(value, str) or not value.strip():
105
+ raise RuleError("global rule patterns must be non-empty strings")
106
+ pattern = value.strip()
107
+ supplied = PurePosixPath(
108
+ pattern if trailing_slash_tree else pattern.rstrip("/")
109
+ )
110
+ if supplied.is_absolute() or ".." in supplied.parts:
111
+ raise RuleError("global rules must be relative to the profile root")
112
+ if trailing_slash_tree and pattern.endswith("/"):
113
+ supplied = supplied / "**"
114
+ try:
115
+ normalized.append(FileSelector(supplied.as_posix()).pattern)
116
+ except SelectionError as error:
117
+ raise RuleError(str(error)) from error
118
+ return tuple(normalized)
119
+
120
+
121
+ def patterns_from_remote_operands(
122
+ values: tuple[str, ...],
123
+ *,
124
+ profile_root: Path,
125
+ current_directory: Path,
126
+ ) -> tuple[str, ...]:
127
+ """Normalize declarative remote paths without consulting either filesystem."""
128
+ base = _relative_base(profile_root, current_directory)
129
+ normalized: list[str] = []
130
+ for value in values:
131
+ if not isinstance(value, str) or not value.strip():
132
+ raise RuleError("remote rule paths must be non-empty strings")
133
+ pattern = value.strip()
134
+ supplied = PurePosixPath(pattern.rstrip("/"))
135
+ if supplied.is_absolute() or not supplied.parts or ".." in supplied.parts:
136
+ raise RuleError(
137
+ "remote rule paths must be relative to the profile or current directory"
138
+ )
139
+ try:
140
+ normalized.append(FileSelector((base / supplied).as_posix()).pattern)
141
+ except SelectionError as error:
142
+ raise RuleError(str(error)) from error
143
+ return tuple(normalized)
144
+
145
+
146
+ @dataclass(frozen=True)
147
+ class SyncRule:
148
+ id: int
149
+ action: RuleAction
150
+ pattern: str
151
+ target: RuleTarget = "local"
152
+
153
+ def __post_init__(self) -> None:
154
+ if isinstance(self.id, bool) or not isinstance(self.id, int) or self.id < 1:
155
+ raise RuleError("rule id must be a positive integer")
156
+ if not isinstance(self.action, str) or self.action not in {
157
+ "include",
158
+ "exclude",
159
+ }:
160
+ raise RuleError("rule action must be 'include' or 'exclude'")
161
+ if not isinstance(self.pattern, str):
162
+ raise RuleError("rule pattern must be a string")
163
+ if self.target not in {"local", "remote"}:
164
+ raise RuleError("rule target must be 'local' or 'remote'")
165
+ try:
166
+ normalized = FileSelector(self.pattern).pattern
167
+ except SelectionError as error:
168
+ raise RuleError(str(error)) from error
169
+ object.__setattr__(self, "pattern", normalized)
170
+ object.__setattr__(self, "_selector", FileSelector(normalized))
171
+
172
+ def matches(self, profile_relative_path: str) -> bool:
173
+ return self._selector.matches(profile_relative_path)
174
+
175
+ def may_match_descendant(self, profile_relative_directory: str) -> bool:
176
+ return self._selector.may_match_descendant(profile_relative_directory)
177
+
178
+ def to_dict(self) -> dict[str, Any]:
179
+ value = {
180
+ "id": self.id,
181
+ "action": self.action,
182
+ "pattern": self.pattern,
183
+ }
184
+ if self.target == "remote":
185
+ value["target"] = self.target
186
+ return value
187
+
188
+ @classmethod
189
+ def from_dict(cls, value: Any) -> SyncRule:
190
+ required = {"id", "action", "pattern"}
191
+ if (
192
+ not isinstance(value, dict)
193
+ or not required.issubset(value)
194
+ or set(value) - required != ({"target"} if "target" in value else set())
195
+ ):
196
+ raise RuleError(
197
+ "each rule must contain id, action, and pattern, with only an "
198
+ "optional target"
199
+ )
200
+ return cls(
201
+ id=value["id"],
202
+ action=value["action"],
203
+ pattern=value["pattern"],
204
+ target=value.get("target", "local"),
205
+ )
206
+
207
+
208
+ @dataclass(frozen=True)
209
+ class RuleSet:
210
+ rules: tuple[SyncRule, ...] = ()
211
+ gitignores: GitIgnores | None = None
212
+
213
+ def __post_init__(self) -> None:
214
+ if any(not isinstance(rule, SyncRule) for rule in self.rules):
215
+ raise RuleError("rules must contain structured synchronization rules")
216
+ ids = tuple(rule.id for rule in self.rules)
217
+ if len(set(ids)) != len(ids):
218
+ raise RuleError("rule ids must be unique")
219
+ if ids != tuple(sorted(ids)):
220
+ raise RuleError("rules must be ordered by increasing id")
221
+
222
+ @classmethod
223
+ def layered(
224
+ cls, *layers: tuple[SyncRule, ...], gitignores: GitIgnores | None = None
225
+ ) -> RuleSet:
226
+ return cls(
227
+ tuple(
228
+ SyncRule(index, rule.action, rule.pattern, rule.target)
229
+ for index, rule in enumerate(
230
+ (rule for layer in layers for rule in layer),
231
+ start=1,
232
+ )
233
+ ),
234
+ gitignores=gitignores,
235
+ )
236
+
237
+ def excludes(
238
+ self,
239
+ relative_path: str,
240
+ *,
241
+ target: RuleTarget = "local",
242
+ is_directory: bool = False,
243
+ ) -> bool:
244
+ profile_relative_path = relative_path
245
+ path = PurePosixPath(profile_relative_path)
246
+ if path.is_absolute() or not path.parts or ".." in path.parts:
247
+ raise RuleError("evaluated paths must be non-empty profile-relative paths")
248
+ candidate = path.as_posix()
249
+ winner = next(
250
+ (
251
+ rule
252
+ for rule in reversed(self.rules)
253
+ if rule.target == target and rule.matches(candidate)
254
+ ),
255
+ None,
256
+ )
257
+ if winner is not None:
258
+ return winner.action == "exclude"
259
+ return bool(
260
+ target == "local"
261
+ and self.gitignores is not None
262
+ and self.gitignores.excludes(candidate, is_directory=is_directory)
263
+ )
264
+
265
+ def may_include_descendant(
266
+ self,
267
+ relative_directory: str,
268
+ *,
269
+ target: RuleTarget = "local",
270
+ ) -> bool:
271
+ return any(
272
+ rule.action == "include"
273
+ and rule.target == target
274
+ and rule.may_match_descendant(relative_directory)
275
+ for rule in self.rules
276
+ )
hlsync/selection.py ADDED
@@ -0,0 +1,159 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from fnmatch import fnmatchcase
5
+ from functools import cache
6
+ from pathlib import Path, PurePosixPath
7
+
8
+
9
+ class SelectionError(ValueError):
10
+ """Raised when a file selector is unsafe or matches no paths."""
11
+
12
+
13
+ @cache
14
+ def _matches_parts(pattern: tuple[str, ...], path: tuple[str, ...]) -> bool:
15
+ if not pattern:
16
+ return not path
17
+ head, *tail = pattern
18
+ if head == "**":
19
+ return _matches_parts(tuple(tail), path) or (
20
+ bool(path) and _matches_parts(pattern, path[1:])
21
+ )
22
+ return bool(path) and fnmatchcase(path[0], head) and _matches_parts(
23
+ tuple(tail), path[1:]
24
+ )
25
+
26
+
27
+ @cache
28
+ def _can_match_descendant(
29
+ pattern: tuple[str, ...],
30
+ directory: tuple[str, ...],
31
+ ) -> bool:
32
+ if not directory:
33
+ return bool(pattern)
34
+ if not pattern:
35
+ return False
36
+ head, *tail = pattern
37
+ if head == "**":
38
+ return _can_match_descendant(tuple(tail), directory) or (
39
+ _can_match_descendant(pattern, directory[1:])
40
+ )
41
+ return fnmatchcase(directory[0], head) and _can_match_descendant(
42
+ tuple(tail), directory[1:]
43
+ )
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class FileSelector:
48
+ pattern: str
49
+
50
+ def __post_init__(self) -> None:
51
+ path = PurePosixPath(self.pattern)
52
+ if path.is_absolute() or not path.parts or ".." in path.parts:
53
+ raise SelectionError(
54
+ "file selector must be a non-empty relative path without '..'"
55
+ )
56
+ if any(character in self.pattern for character in "?["):
57
+ raise SelectionError("file selector supports only '*' and '**' wildcards")
58
+ if any("**" in part and part != "**" for part in path.parts):
59
+ raise SelectionError("'**' must occupy an entire path segment")
60
+ object.__setattr__(self, "pattern", path.as_posix())
61
+
62
+ @classmethod
63
+ def from_argument(
64
+ cls,
65
+ value: str,
66
+ *,
67
+ profile_root: Path,
68
+ current_directory: Path,
69
+ ) -> FileSelector:
70
+ if not value:
71
+ raise SelectionError("file selector cannot be empty")
72
+ try:
73
+ current_relative = current_directory.relative_to(profile_root)
74
+ except ValueError:
75
+ base = PurePosixPath()
76
+ else:
77
+ base = PurePosixPath(*current_relative.parts)
78
+ supplied = PurePosixPath(value)
79
+ if supplied.is_absolute() or ".." in supplied.parts:
80
+ raise SelectionError(
81
+ "file selector must be relative to the profile or current directory"
82
+ )
83
+ return cls((base / supplied).as_posix())
84
+
85
+ def matches(self, profile_relative_path: str) -> bool:
86
+ path = PurePosixPath(profile_relative_path)
87
+ if path.is_absolute() or ".." in path.parts:
88
+ raise SelectionError("selected paths must remain inside the profile")
89
+ return _matches_parts(PurePosixPath(self.pattern).parts, path.parts)
90
+
91
+ def may_match_descendant(self, profile_relative_directory: str) -> bool:
92
+ directory = PurePosixPath(profile_relative_directory)
93
+ if directory.is_absolute() or ".." in directory.parts:
94
+ raise SelectionError("selected paths must remain inside the profile")
95
+ return _can_match_descendant(
96
+ PurePosixPath(self.pattern).parts,
97
+ directory.parts,
98
+ )
99
+
100
+
101
+ @dataclass(frozen=True)
102
+ class FileSelectorSet:
103
+ selectors: tuple[FileSelector, ...]
104
+
105
+ def __post_init__(self) -> None:
106
+ if not self.selectors:
107
+ raise SelectionError("file selector set cannot be empty")
108
+ unique = {selector.pattern: selector for selector in self.selectors}
109
+ object.__setattr__(
110
+ self,
111
+ "selectors",
112
+ tuple(unique[pattern] for pattern in sorted(unique)),
113
+ )
114
+
115
+ @property
116
+ def pattern(self) -> str:
117
+ return ", ".join(selector.pattern for selector in self.selectors)
118
+
119
+ def matches(self, profile_relative_path: str) -> bool:
120
+ return any(
121
+ selector.matches(profile_relative_path) for selector in self.selectors
122
+ )
123
+
124
+ def may_match_descendant(self, profile_relative_directory: str) -> bool:
125
+ return any(
126
+ selector.may_match_descendant(profile_relative_directory)
127
+ for selector in self.selectors
128
+ )
129
+
130
+
131
+ FileSelection = FileSelector | FileSelectorSet
132
+
133
+
134
+ @dataclass(frozen=True)
135
+ class DirectoryContentsSelection:
136
+ """Select operands while treating matched directories as containers."""
137
+
138
+ traversal: FileSelection
139
+ containers: FileSelection | None = None
140
+
141
+ @property
142
+ def pattern(self) -> str:
143
+ return self.traversal.pattern
144
+
145
+ def matches(self, profile_relative_path: str) -> bool:
146
+ return self.traversal.matches(profile_relative_path)
147
+
148
+ def may_match_descendant(self, profile_relative_directory: str) -> bool:
149
+ return self.traversal.may_match_descendant(profile_relative_directory)
150
+
151
+ def includes_result(self, profile_relative_path: str, *, directory: bool) -> bool:
152
+ return not (
153
+ directory
154
+ and self.containers is not None
155
+ and self.containers.matches(profile_relative_path)
156
+ )
157
+
158
+
159
+ FileSelection = FileSelector | FileSelectorSet | DirectoryContentsSelection
hlsync/snapshot.py ADDED
@@ -0,0 +1,193 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from collections.abc import Callable
5
+ from dataclasses import dataclass
6
+ from pathlib import Path, PurePosixPath
7
+ from typing import Literal
8
+
9
+ from hlsync.rules import RuleSet
10
+ from hlsync.selection import FileSelection
11
+
12
+ EntryKind = Literal["directory", "file", "symlink"]
13
+
14
+
15
+ class SnapshotError(RuntimeError):
16
+ """Raised when a deterministic tree snapshot cannot be produced."""
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class TreeEntry:
21
+ path: str
22
+ kind: EntryKind
23
+ size: int | None = None
24
+ modified_ns: int | None = None
25
+ timestamp_precision_ns: int | None = None
26
+ excluded: bool = False
27
+ remote_excluded: bool = False
28
+
29
+ def __post_init__(self) -> None:
30
+ path = PurePosixPath(self.path)
31
+ if path.is_absolute() or not path.parts or ".." in path.parts:
32
+ raise SnapshotError(f"snapshot path must be relative: {self.path!r}")
33
+ object.__setattr__(self, "path", path.as_posix())
34
+ if not isinstance(self.excluded, bool):
35
+ raise SnapshotError("snapshot exclusion state must be a boolean")
36
+ if not isinstance(self.remote_excluded, bool):
37
+ raise SnapshotError("remote exclusion state must be a boolean")
38
+ metadata = (self.size, self.modified_ns, self.timestamp_precision_ns)
39
+ if self.kind == "file":
40
+ if any(value is None for value in metadata):
41
+ raise SnapshotError(
42
+ f"file snapshot metadata is incomplete: {self.path!r}"
43
+ )
44
+ if self.size is not None and self.size < 0:
45
+ raise SnapshotError(f"file size cannot be negative: {self.path!r}")
46
+ if (
47
+ self.timestamp_precision_ns is not None
48
+ and self.timestamp_precision_ns <= 0
49
+ ):
50
+ raise SnapshotError(
51
+ f"timestamp precision must be positive: {self.path!r}"
52
+ )
53
+ elif any(value is not None for value in metadata):
54
+ raise SnapshotError(
55
+ f"{self.kind} snapshot entry cannot have file metadata: "
56
+ f"{self.path!r}"
57
+ )
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class TreeSnapshot:
62
+ entries: tuple[TreeEntry, ...] = ()
63
+
64
+ def __post_init__(self) -> None:
65
+ ordered = tuple(sorted(self.entries, key=lambda entry: entry.path))
66
+ if len({entry.path for entry in ordered}) != len(ordered):
67
+ raise SnapshotError("snapshot contains duplicate paths")
68
+ object.__setattr__(self, "entries", ordered)
69
+
70
+
71
+ def snapshot_local(
72
+ root: Path,
73
+ rules: RuleSet,
74
+ selector: FileSelection | None = None,
75
+ *,
76
+ include_excluded: bool = False,
77
+ traverse_excluded: bool = False,
78
+ respect_remote_boundaries: bool = False,
79
+ directory_progress: Callable[[PurePosixPath], None] | None = None,
80
+ ) -> TreeSnapshot:
81
+ if not root.is_dir():
82
+ raise SnapshotError(f"local root is not an accessible directory: {root}")
83
+ entries: list[TreeEntry] = []
84
+
85
+ def walk(relative_directory: PurePosixPath) -> None:
86
+ if directory_progress is not None:
87
+ directory_progress(relative_directory)
88
+ listing = list_local_directory(root, relative_directory, rules)
89
+ descendants: list[PurePosixPath] = []
90
+ for entry in listing.entries:
91
+ relative_path = entry.path
92
+ relative = PurePosixPath(relative_path)
93
+ kind = entry.kind
94
+ excluded = entry.excluded
95
+ visible = not excluded or include_excluded
96
+ selected = visible and (
97
+ selector is None or selector.matches(relative_path)
98
+ )
99
+ if selected:
100
+ entries.append(entry)
101
+ selection_may_descend = (
102
+ selector is None or selector.may_match_descendant(relative_path)
103
+ )
104
+ exclusion_may_descend = (
105
+ traverse_excluded
106
+ or not excluded
107
+ or rules.may_include_descendant(relative_path)
108
+ )
109
+ remote_may_descend = (
110
+ not respect_remote_boundaries or not entry.remote_excluded
111
+ )
112
+ if (
113
+ kind == "directory"
114
+ and selection_may_descend
115
+ and exclusion_may_descend
116
+ and remote_may_descend
117
+ ):
118
+ descendants.append(relative)
119
+ for descendant in descendants:
120
+ walk(descendant)
121
+
122
+ walk(PurePosixPath())
123
+ return TreeSnapshot(tuple(entries))
124
+
125
+
126
+ def list_local_directory(
127
+ root: Path,
128
+ relative_directory: PurePosixPath,
129
+ rules: RuleSet,
130
+ ) -> TreeSnapshot:
131
+ """Inspect one directory and return all immediate children."""
132
+ directory = root / relative_directory
133
+ try:
134
+ with os.scandir(directory) as iterator:
135
+ children = sorted(iterator, key=lambda entry: entry.name)
136
+ except OSError as error:
137
+ raise SnapshotError(
138
+ f"could not read local directory '{directory}': {error}"
139
+ ) from error
140
+
141
+ entries: list[TreeEntry] = []
142
+ for child in children:
143
+ relative = relative_directory / child.name
144
+ relative_path = relative.as_posix()
145
+ try:
146
+ if child.is_symlink():
147
+ kind: EntryKind = "symlink"
148
+ elif child.is_dir(follow_symlinks=False):
149
+ kind = "directory"
150
+ elif child.is_file(follow_symlinks=False):
151
+ kind = "file"
152
+ else:
153
+ raise SnapshotError(
154
+ f"unsupported local entry type: {directory / child.name}"
155
+ )
156
+ except OSError as error:
157
+ raise SnapshotError(
158
+ f"could not inspect local path '{directory / child.name}': {error}"
159
+ ) from error
160
+
161
+ excluded = rules.excludes(relative_path, is_directory=kind == "directory")
162
+ remote_excluded = rules.excludes(
163
+ relative_path, target="remote", is_directory=kind == "directory"
164
+ )
165
+ if kind == "file":
166
+ try:
167
+ stat = child.stat(follow_symlinks=False)
168
+ except OSError as error:
169
+ raise SnapshotError(
170
+ f"could not read local file metadata '{directory / child.name}': "
171
+ f"{error}"
172
+ ) from error
173
+ entries.append(
174
+ TreeEntry(
175
+ relative_path,
176
+ kind,
177
+ size=stat.st_size,
178
+ modified_ns=stat.st_mtime_ns,
179
+ timestamp_precision_ns=1,
180
+ excluded=excluded,
181
+ remote_excluded=remote_excluded,
182
+ )
183
+ )
184
+ else:
185
+ entries.append(
186
+ TreeEntry(
187
+ relative_path,
188
+ kind,
189
+ excluded=excluded,
190
+ remote_excluded=remote_excluded,
191
+ )
192
+ )
193
+ return TreeSnapshot(tuple(entries))