agents-function-tools 0.2.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.
- agents_function_tools-0.2.0.dist-info/METADATA +90 -0
- agents_function_tools-0.2.0.dist-info/RECORD +14 -0
- agents_function_tools-0.2.0.dist-info/WHEEL +5 -0
- agents_function_tools-0.2.0.dist-info/licenses/LICENSE +202 -0
- agents_function_tools-0.2.0.dist-info/top_level.txt +1 -0
- function_tools/__init__.py +21 -0
- function_tools/archive.py +220 -0
- function_tools/command.py +172 -0
- function_tools/errors.py +11 -0
- function_tools/host.py +43 -0
- function_tools/http.py +136 -0
- function_tools/openai_tools.py +398 -0
- function_tools/responses.py +39 -0
- function_tools/workspace.py +433 -0
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import tempfile
|
|
6
|
+
from fnmatch import fnmatchcase
|
|
7
|
+
from hashlib import new as new_hash
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .errors import FoundationToolError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Workspace:
|
|
15
|
+
"""Filesystem operations confined to one configured directory."""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
root: str | Path,
|
|
20
|
+
*,
|
|
21
|
+
max_read_bytes: int = 1_000_000,
|
|
22
|
+
max_write_bytes: int = 1_000_000,
|
|
23
|
+
) -> None:
|
|
24
|
+
resolved_root = Path(root).resolve(strict=True)
|
|
25
|
+
if not resolved_root.is_dir():
|
|
26
|
+
raise FoundationToolError("INVALID_ROOT", "Workspace root is not a directory.")
|
|
27
|
+
if max_read_bytes <= 0 or max_write_bytes <= 0:
|
|
28
|
+
raise FoundationToolError("INVALID_LIMIT", "File byte limits must be positive.")
|
|
29
|
+
|
|
30
|
+
self.root = resolved_root
|
|
31
|
+
self.max_read_bytes = max_read_bytes
|
|
32
|
+
self.max_write_bytes = max_write_bytes
|
|
33
|
+
|
|
34
|
+
def list_directory(
|
|
35
|
+
self, path: str = ".", *, recursive: bool = False, max_entries: int = 200
|
|
36
|
+
) -> dict[str, Any]:
|
|
37
|
+
if not 1 <= max_entries <= 10_000:
|
|
38
|
+
raise FoundationToolError("INVALID_LIMIT", "max_entries must be between 1 and 10000.")
|
|
39
|
+
directory = self.resolve_directory(path)
|
|
40
|
+
entries: list[dict[str, Any]] = []
|
|
41
|
+
truncated = False
|
|
42
|
+
|
|
43
|
+
if recursive:
|
|
44
|
+
iterator = self._walk_entries(directory)
|
|
45
|
+
else:
|
|
46
|
+
iterator = iter(sorted(directory.iterdir(), key=lambda item: item.name.lower()))
|
|
47
|
+
|
|
48
|
+
for entry in iterator:
|
|
49
|
+
if len(entries) == max_entries:
|
|
50
|
+
truncated = True
|
|
51
|
+
break
|
|
52
|
+
entries.append(self._entry_metadata(entry))
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
"path": self._relative(directory),
|
|
56
|
+
"entries": entries,
|
|
57
|
+
"truncated": truncated,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
def read_text(self, path: str, *, max_bytes: int | None = None) -> dict[str, Any]:
|
|
61
|
+
file_path = self._resolve(path)
|
|
62
|
+
if not file_path.is_file():
|
|
63
|
+
raise FoundationToolError("NOT_A_FILE", "Path is not a regular file.")
|
|
64
|
+
|
|
65
|
+
limit = self.max_read_bytes if max_bytes is None else max_bytes
|
|
66
|
+
if not 1 <= limit <= self.max_read_bytes:
|
|
67
|
+
raise FoundationToolError(
|
|
68
|
+
"INVALID_LIMIT",
|
|
69
|
+
f"max_bytes must be between 1 and {self.max_read_bytes}.",
|
|
70
|
+
)
|
|
71
|
+
size = file_path.stat().st_size
|
|
72
|
+
if size > limit:
|
|
73
|
+
raise FoundationToolError(
|
|
74
|
+
"FILE_TOO_LARGE", f"File is {size} bytes; the read limit is {limit}."
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
content = file_path.read_text(encoding="utf-8")
|
|
79
|
+
except UnicodeDecodeError as error:
|
|
80
|
+
raise FoundationToolError("NOT_UTF8", "Only UTF-8 text files can be read.") from error
|
|
81
|
+
except OSError as error:
|
|
82
|
+
raise FoundationToolError("READ_FAILED", str(error), retryable=True) from error
|
|
83
|
+
|
|
84
|
+
return {"path": self._relative(file_path), "content": content, "bytes": size}
|
|
85
|
+
|
|
86
|
+
def stat_path(self, path: str) -> dict[str, Any]:
|
|
87
|
+
"""Return non-content metadata for one workspace path."""
|
|
88
|
+
|
|
89
|
+
target = self._resolve(path)
|
|
90
|
+
if not target.exists() and not target.is_symlink():
|
|
91
|
+
raise FoundationToolError("NOT_FOUND", "Path does not exist.")
|
|
92
|
+
metadata = self._entry_metadata(target)
|
|
93
|
+
try:
|
|
94
|
+
stat = target.lstat()
|
|
95
|
+
except OSError as error:
|
|
96
|
+
raise FoundationToolError("STAT_FAILED", str(error), retryable=True) from error
|
|
97
|
+
metadata.update(
|
|
98
|
+
{
|
|
99
|
+
"modified_at_epoch": stat.st_mtime,
|
|
100
|
+
"created_at_epoch": stat.st_ctime,
|
|
101
|
+
}
|
|
102
|
+
)
|
|
103
|
+
return metadata
|
|
104
|
+
|
|
105
|
+
def find_paths(
|
|
106
|
+
self,
|
|
107
|
+
pattern: str,
|
|
108
|
+
*,
|
|
109
|
+
path: str = ".",
|
|
110
|
+
kind: str = "any",
|
|
111
|
+
max_entries: int = 200,
|
|
112
|
+
) -> dict[str, Any]:
|
|
113
|
+
"""Find workspace entries using a case-insensitive glob-style filename pattern."""
|
|
114
|
+
|
|
115
|
+
if not pattern or "\x00" in pattern:
|
|
116
|
+
raise FoundationToolError("INVALID_PATTERN", "pattern must be a non-empty string.")
|
|
117
|
+
if kind not in {"any", "file", "directory", "symlink"}:
|
|
118
|
+
raise FoundationToolError(
|
|
119
|
+
"INVALID_KIND", "kind must be any, file, directory, or symlink."
|
|
120
|
+
)
|
|
121
|
+
if not 1 <= max_entries <= 10_000:
|
|
122
|
+
raise FoundationToolError("INVALID_LIMIT", "max_entries must be between 1 and 10000.")
|
|
123
|
+
|
|
124
|
+
directory = self.resolve_directory(path)
|
|
125
|
+
matches: list[dict[str, Any]] = []
|
|
126
|
+
truncated = False
|
|
127
|
+
for candidate in self._walk_entries(directory):
|
|
128
|
+
metadata = self._entry_metadata(candidate)
|
|
129
|
+
if kind != "any" and metadata["kind"] != kind:
|
|
130
|
+
continue
|
|
131
|
+
relative = self._relative(candidate)
|
|
132
|
+
if not (
|
|
133
|
+
fnmatchcase(relative.lower(), pattern.lower())
|
|
134
|
+
or fnmatchcase(Path(relative).name.lower(), pattern.lower())
|
|
135
|
+
):
|
|
136
|
+
continue
|
|
137
|
+
if len(matches) == max_entries:
|
|
138
|
+
truncated = True
|
|
139
|
+
break
|
|
140
|
+
matches.append(metadata)
|
|
141
|
+
return {
|
|
142
|
+
"path": self._relative(directory),
|
|
143
|
+
"pattern": pattern,
|
|
144
|
+
"entries": matches,
|
|
145
|
+
"truncated": truncated,
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
def hash_file(self, path: str, *, algorithm: str = "sha256") -> dict[str, Any]:
|
|
149
|
+
"""Calculate a bounded cryptographic hash for one regular file."""
|
|
150
|
+
|
|
151
|
+
if algorithm not in {"sha256", "sha512", "blake2b"}:
|
|
152
|
+
raise FoundationToolError(
|
|
153
|
+
"INVALID_ALGORITHM", "algorithm must be sha256, sha512, or blake2b."
|
|
154
|
+
)
|
|
155
|
+
file_path = self._resolve(path)
|
|
156
|
+
if not file_path.is_file():
|
|
157
|
+
raise FoundationToolError("NOT_A_FILE", "Path is not a regular file.")
|
|
158
|
+
size = file_path.stat().st_size
|
|
159
|
+
if size > self.max_read_bytes:
|
|
160
|
+
raise FoundationToolError(
|
|
161
|
+
"FILE_TOO_LARGE", f"File is {size} bytes; the hash limit is {self.max_read_bytes}."
|
|
162
|
+
)
|
|
163
|
+
digest = new_hash(algorithm)
|
|
164
|
+
try:
|
|
165
|
+
with file_path.open("rb") as handle:
|
|
166
|
+
for chunk in iter(lambda: handle.read(64 * 1024), b""):
|
|
167
|
+
digest.update(chunk)
|
|
168
|
+
except OSError as error:
|
|
169
|
+
raise FoundationToolError("HASH_FAILED", str(error), retryable=True) from error
|
|
170
|
+
return {
|
|
171
|
+
"path": self._relative(file_path),
|
|
172
|
+
"algorithm": algorithm,
|
|
173
|
+
"digest": digest.hexdigest(),
|
|
174
|
+
"bytes": size,
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
def write_text(
|
|
178
|
+
self,
|
|
179
|
+
path: str,
|
|
180
|
+
content: str,
|
|
181
|
+
*,
|
|
182
|
+
overwrite: bool = False,
|
|
183
|
+
create_parents: bool = False,
|
|
184
|
+
) -> dict[str, Any]:
|
|
185
|
+
file_path = self._resolve(path, allow_root=False)
|
|
186
|
+
encoded = content.encode("utf-8")
|
|
187
|
+
if len(encoded) > self.max_write_bytes:
|
|
188
|
+
raise FoundationToolError(
|
|
189
|
+
"CONTENT_TOO_LARGE",
|
|
190
|
+
f"Content is {len(encoded)} bytes; the write limit is {self.max_write_bytes}.",
|
|
191
|
+
)
|
|
192
|
+
if file_path.exists() and file_path.is_dir():
|
|
193
|
+
raise FoundationToolError("IS_DIRECTORY", "Cannot write text to a directory.")
|
|
194
|
+
if file_path.exists() and not overwrite:
|
|
195
|
+
raise FoundationToolError(
|
|
196
|
+
"ALREADY_EXISTS", "File exists; set overwrite=true to replace it."
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
parent = file_path.parent
|
|
200
|
+
try:
|
|
201
|
+
if create_parents:
|
|
202
|
+
parent.mkdir(parents=True, exist_ok=True)
|
|
203
|
+
elif not parent.is_dir():
|
|
204
|
+
raise FoundationToolError("PARENT_NOT_FOUND", "Parent directory does not exist.")
|
|
205
|
+
|
|
206
|
+
temporary_name: str | None = None
|
|
207
|
+
with tempfile.NamedTemporaryFile(
|
|
208
|
+
mode="wb", dir=parent, prefix=".agent-write-", delete=False
|
|
209
|
+
) as temporary:
|
|
210
|
+
temporary.write(encoded)
|
|
211
|
+
temporary.flush()
|
|
212
|
+
os.fsync(temporary.fileno())
|
|
213
|
+
temporary_name = temporary.name
|
|
214
|
+
os.replace(temporary_name, file_path)
|
|
215
|
+
except FoundationToolError:
|
|
216
|
+
raise
|
|
217
|
+
except OSError as error:
|
|
218
|
+
if "temporary_name" in locals() and temporary_name:
|
|
219
|
+
Path(temporary_name).unlink(missing_ok=True)
|
|
220
|
+
raise FoundationToolError("WRITE_FAILED", str(error), retryable=True) from error
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
"path": self._relative(file_path),
|
|
224
|
+
"bytes": len(encoded),
|
|
225
|
+
"overwritten": overwrite,
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
def create_directory(
|
|
229
|
+
self, path: str, *, parents: bool = True, exist_ok: bool = False
|
|
230
|
+
) -> dict[str, Any]:
|
|
231
|
+
directory = self._resolve(path, allow_root=False)
|
|
232
|
+
try:
|
|
233
|
+
directory.mkdir(parents=parents, exist_ok=exist_ok)
|
|
234
|
+
except FileExistsError as error:
|
|
235
|
+
raise FoundationToolError("ALREADY_EXISTS", "Path already exists.") from error
|
|
236
|
+
except FileNotFoundError as error:
|
|
237
|
+
raise FoundationToolError(
|
|
238
|
+
"PARENT_NOT_FOUND", "Parent directory does not exist."
|
|
239
|
+
) from error
|
|
240
|
+
except OSError as error:
|
|
241
|
+
raise FoundationToolError("CREATE_FAILED", str(error)) from error
|
|
242
|
+
return {"path": self._relative(directory), "created": True}
|
|
243
|
+
|
|
244
|
+
def copy_file(
|
|
245
|
+
self,
|
|
246
|
+
source: str,
|
|
247
|
+
destination: str,
|
|
248
|
+
*,
|
|
249
|
+
overwrite: bool = False,
|
|
250
|
+
create_parents: bool = False,
|
|
251
|
+
) -> dict[str, Any]:
|
|
252
|
+
"""Copy one regular file without following source symlinks."""
|
|
253
|
+
|
|
254
|
+
source_path = self._resolve(source)
|
|
255
|
+
destination_path = self._resolve(destination, allow_root=False)
|
|
256
|
+
if not source_path.is_file() or source_path.is_symlink():
|
|
257
|
+
raise FoundationToolError("NOT_A_FILE", "source must be a regular non-symlink file.")
|
|
258
|
+
size = source_path.stat().st_size
|
|
259
|
+
if size > self.max_write_bytes:
|
|
260
|
+
raise FoundationToolError(
|
|
261
|
+
"CONTENT_TOO_LARGE",
|
|
262
|
+
f"Source is {size} bytes; the copy limit is {self.max_write_bytes}.",
|
|
263
|
+
)
|
|
264
|
+
if destination_path.exists() and not overwrite:
|
|
265
|
+
raise FoundationToolError(
|
|
266
|
+
"ALREADY_EXISTS", "Destination exists; set overwrite=true to replace it."
|
|
267
|
+
)
|
|
268
|
+
if destination_path.exists() and destination_path.is_dir():
|
|
269
|
+
raise FoundationToolError("IS_DIRECTORY", "Destination cannot be a directory.")
|
|
270
|
+
try:
|
|
271
|
+
if create_parents:
|
|
272
|
+
destination_path.parent.mkdir(parents=True, exist_ok=True)
|
|
273
|
+
elif not destination_path.parent.is_dir():
|
|
274
|
+
raise FoundationToolError("PARENT_NOT_FOUND", "Parent directory does not exist.")
|
|
275
|
+
shutil.copy2(source_path, destination_path)
|
|
276
|
+
except FoundationToolError:
|
|
277
|
+
raise
|
|
278
|
+
except OSError as error:
|
|
279
|
+
raise FoundationToolError("COPY_FAILED", str(error), retryable=True) from error
|
|
280
|
+
return {
|
|
281
|
+
"source": self._relative(source_path),
|
|
282
|
+
"destination": self._relative(destination_path),
|
|
283
|
+
"bytes": size,
|
|
284
|
+
"overwritten": overwrite,
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
def move_path(
|
|
288
|
+
self,
|
|
289
|
+
source: str,
|
|
290
|
+
destination: str,
|
|
291
|
+
*,
|
|
292
|
+
overwrite: bool = False,
|
|
293
|
+
create_parents: bool = False,
|
|
294
|
+
) -> dict[str, Any]:
|
|
295
|
+
"""Move one workspace file or directory to a new workspace-relative path."""
|
|
296
|
+
|
|
297
|
+
source_path = self._resolve(source, allow_root=False)
|
|
298
|
+
destination_path = self._resolve(destination, allow_root=False)
|
|
299
|
+
if not source_path.exists() and not source_path.is_symlink():
|
|
300
|
+
raise FoundationToolError("NOT_FOUND", "Source path does not exist.")
|
|
301
|
+
if source_path == destination_path:
|
|
302
|
+
raise FoundationToolError("SAME_PATH", "Source and destination must differ.")
|
|
303
|
+
if destination_path.exists() or destination_path.is_symlink():
|
|
304
|
+
if not overwrite:
|
|
305
|
+
raise FoundationToolError(
|
|
306
|
+
"ALREADY_EXISTS", "Destination exists; set overwrite=true to replace it."
|
|
307
|
+
)
|
|
308
|
+
if destination_path.is_dir() and not destination_path.is_symlink():
|
|
309
|
+
raise FoundationToolError(
|
|
310
|
+
"DESTINATION_IS_DIRECTORY", "Cannot overwrite a destination directory."
|
|
311
|
+
)
|
|
312
|
+
try:
|
|
313
|
+
if create_parents:
|
|
314
|
+
destination_path.parent.mkdir(parents=True, exist_ok=True)
|
|
315
|
+
elif not destination_path.parent.is_dir():
|
|
316
|
+
raise FoundationToolError("PARENT_NOT_FOUND", "Parent directory does not exist.")
|
|
317
|
+
if destination_path.exists() or destination_path.is_symlink():
|
|
318
|
+
destination_path.unlink()
|
|
319
|
+
shutil.move(str(source_path), str(destination_path))
|
|
320
|
+
except FoundationToolError:
|
|
321
|
+
raise
|
|
322
|
+
except OSError as error:
|
|
323
|
+
raise FoundationToolError("MOVE_FAILED", str(error), retryable=True) from error
|
|
324
|
+
return {
|
|
325
|
+
"source": self._relative(source_path),
|
|
326
|
+
"destination": self._relative(destination_path),
|
|
327
|
+
"moved": True,
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
def disk_usage(self) -> dict[str, Any]:
|
|
331
|
+
"""Return storage capacity visible to the configured workspace."""
|
|
332
|
+
|
|
333
|
+
try:
|
|
334
|
+
usage = shutil.disk_usage(self.root)
|
|
335
|
+
except OSError as error:
|
|
336
|
+
raise FoundationToolError("DISK_USAGE_FAILED", str(error), retryable=True) from error
|
|
337
|
+
return {
|
|
338
|
+
"path": ".",
|
|
339
|
+
"total_bytes": usage.total,
|
|
340
|
+
"used_bytes": usage.used,
|
|
341
|
+
"free_bytes": usage.free,
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
def delete_path(self, path: str, *, recursive: bool = False) -> dict[str, Any]:
|
|
345
|
+
target = self._resolve(path, allow_root=False)
|
|
346
|
+
if not target.exists() and not target.is_symlink():
|
|
347
|
+
raise FoundationToolError("NOT_FOUND", "Path does not exist.")
|
|
348
|
+
|
|
349
|
+
kind = "file"
|
|
350
|
+
try:
|
|
351
|
+
if target.is_symlink():
|
|
352
|
+
kind = "symlink"
|
|
353
|
+
target.unlink()
|
|
354
|
+
elif target.is_dir():
|
|
355
|
+
kind = "directory"
|
|
356
|
+
if recursive:
|
|
357
|
+
shutil.rmtree(target)
|
|
358
|
+
else:
|
|
359
|
+
try:
|
|
360
|
+
target.rmdir()
|
|
361
|
+
except OSError as error:
|
|
362
|
+
raise FoundationToolError(
|
|
363
|
+
"DIRECTORY_NOT_EMPTY",
|
|
364
|
+
"Directory is not empty; set recursive=true to delete it.",
|
|
365
|
+
) from error
|
|
366
|
+
else:
|
|
367
|
+
target.unlink()
|
|
368
|
+
except FoundationToolError:
|
|
369
|
+
raise
|
|
370
|
+
except OSError as error:
|
|
371
|
+
raise FoundationToolError("DELETE_FAILED", str(error), retryable=True) from error
|
|
372
|
+
|
|
373
|
+
return {"path": self._relative(target), "deleted": True, "kind": kind}
|
|
374
|
+
|
|
375
|
+
def resolve_directory(self, path: str) -> Path:
|
|
376
|
+
directory = self._resolve(path)
|
|
377
|
+
if not directory.is_dir():
|
|
378
|
+
raise FoundationToolError("NOT_A_DIRECTORY", "Path is not a directory.")
|
|
379
|
+
return directory
|
|
380
|
+
|
|
381
|
+
def relative_path(self, path: Path) -> str:
|
|
382
|
+
"""Return a workspace-relative display path for an already resolved path."""
|
|
383
|
+
|
|
384
|
+
return self._relative(path)
|
|
385
|
+
|
|
386
|
+
def _resolve(self, path: str, *, allow_root: bool = True) -> Path:
|
|
387
|
+
if not path or "\x00" in path:
|
|
388
|
+
raise FoundationToolError("INVALID_PATH", "Path must be a non-empty string.")
|
|
389
|
+
relative = Path(path)
|
|
390
|
+
if relative.is_absolute():
|
|
391
|
+
raise FoundationToolError("ABSOLUTE_PATH", "Absolute paths are not allowed.")
|
|
392
|
+
|
|
393
|
+
candidate = (self.root / relative).resolve(strict=False)
|
|
394
|
+
try:
|
|
395
|
+
candidate.relative_to(self.root)
|
|
396
|
+
except ValueError as error:
|
|
397
|
+
raise FoundationToolError(
|
|
398
|
+
"PATH_OUTSIDE_WORKSPACE", "Path resolves outside the workspace."
|
|
399
|
+
) from error
|
|
400
|
+
if not allow_root and candidate == self.root:
|
|
401
|
+
raise FoundationToolError(
|
|
402
|
+
"WORKSPACE_ROOT_PROTECTED", "The workspace root cannot be modified or deleted."
|
|
403
|
+
)
|
|
404
|
+
return candidate
|
|
405
|
+
|
|
406
|
+
def _walk_entries(self, directory: Path):
|
|
407
|
+
for current, directories, files in os.walk(directory, followlinks=False):
|
|
408
|
+
current_path = Path(current)
|
|
409
|
+
directories.sort(key=str.lower)
|
|
410
|
+
files.sort(key=str.lower)
|
|
411
|
+
for name in directories:
|
|
412
|
+
yield current_path / name
|
|
413
|
+
for name in files:
|
|
414
|
+
yield current_path / name
|
|
415
|
+
|
|
416
|
+
def _entry_metadata(self, entry: Path) -> dict[str, Any]:
|
|
417
|
+
if entry.is_symlink():
|
|
418
|
+
kind = "symlink"
|
|
419
|
+
size = None
|
|
420
|
+
elif entry.is_dir():
|
|
421
|
+
kind = "directory"
|
|
422
|
+
size = None
|
|
423
|
+
elif entry.is_file():
|
|
424
|
+
kind = "file"
|
|
425
|
+
size = entry.stat().st_size
|
|
426
|
+
else:
|
|
427
|
+
kind = "other"
|
|
428
|
+
size = None
|
|
429
|
+
return {"path": self._relative(entry), "kind": kind, "bytes": size}
|
|
430
|
+
|
|
431
|
+
def _relative(self, path: Path) -> str:
|
|
432
|
+
relative = path.relative_to(self.root)
|
|
433
|
+
return "." if not relative.parts else relative.as_posix()
|