co-agent-coder 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.
Files changed (51) hide show
  1. co_agent_coder-0.1.0.dist-info/METADATA +396 -0
  2. co_agent_coder-0.1.0.dist-info/RECORD +51 -0
  3. co_agent_coder-0.1.0.dist-info/WHEEL +5 -0
  4. co_agent_coder-0.1.0.dist-info/entry_points.txt +5 -0
  5. co_agent_coder-0.1.0.dist-info/licenses/LICENSE +201 -0
  6. co_agent_coder-0.1.0.dist-info/top_level.txt +1 -0
  7. coagent/__init__.py +84 -0
  8. coagent/__main__.py +6 -0
  9. coagent/agent/__init__.py +27 -0
  10. coagent/agent/budget.py +220 -0
  11. coagent/agent/loop.py +1102 -0
  12. coagent/agent/prompts.py +228 -0
  13. coagent/agent/state.py +181 -0
  14. coagent/cli.py +351 -0
  15. coagent/config.py +307 -0
  16. coagent/context/__init__.py +26 -0
  17. coagent/context/artifacts.py +431 -0
  18. coagent/context/repo_map.py +590 -0
  19. coagent/context/retrieval.py +375 -0
  20. coagent/context/tokens.py +93 -0
  21. coagent/evals/__init__.py +0 -0
  22. coagent/gitx/__init__.py +52 -0
  23. coagent/gitx/commit.py +91 -0
  24. coagent/gitx/durable.py +147 -0
  25. coagent/gitx/git.py +351 -0
  26. coagent/gitx/lock.py +157 -0
  27. coagent/gitx/log.py +85 -0
  28. coagent/gitx/resume.py +199 -0
  29. coagent/gitx/session.py +233 -0
  30. coagent/llm/__init__.py +83 -0
  31. coagent/llm/base.py +235 -0
  32. coagent/llm/grammar.py +306 -0
  33. coagent/llm/registry.py +147 -0
  34. coagent/llm/transformers_backend.py +326 -0
  35. coagent/py.typed +0 -0
  36. coagent/safety/__init__.py +24 -0
  37. coagent/safety/denylist.py +146 -0
  38. coagent/safety/policy.py +219 -0
  39. coagent/safety/redact.py +146 -0
  40. coagent/templates/config.toml +104 -0
  41. coagent/tools/__init__.py +155 -0
  42. coagent/tools/artifacts.py +307 -0
  43. coagent/tools/base.py +226 -0
  44. coagent/tools/fs.py +507 -0
  45. coagent/tools/notebooks.py +875 -0
  46. coagent/tools/pyt.py +240 -0
  47. coagent/tools/shell.py +558 -0
  48. coagent/ui/__init__.py +50 -0
  49. coagent/ui/gradio_app.py +353 -0
  50. coagent/ui/notebook.py +342 -0
  51. coagent/ui/render.py +422 -0
coagent/tools/fs.py ADDED
@@ -0,0 +1,507 @@
1
+ """Filesystem tools: read, write, list, grep, and SEARCH/REPLACE editing.
2
+
3
+ Design constraints honoured here:
4
+
5
+ * **Path jail on reads and writes** (DESIGN §6.2, FR-TOL-2) — every path is
6
+ resolved and checked against `Policy` before it is touched.
7
+ * **Read before write** (FR-AGT-4) — `edit_file`/`write_file` (rewrite mode)
8
+ refuse a path the session has not read. This kills the hallucinated-edit
9
+ class of failure for the price of a dict lookup.
10
+ * **Unique SEARCH match** (DESIGN §6.1) — zero matches returns the closest
11
+ region with line numbers; more than one match is rejected with the offsets.
12
+ * **Full rewrite only for new files or files under 200 lines** — large rewrites
13
+ are the biggest source of catastrophic diffs with small models.
14
+ * **Deterministic truncation** (FR-CTX-3) — head/tail with line numbers, never a
15
+ silent mid-file drop.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import difflib
21
+ import re
22
+ from dataclasses import dataclass, field
23
+ from pathlib import Path
24
+ from typing import Any
25
+
26
+ from ..safety.policy import Policy, Verdict
27
+ from .base import ArgSpec, BaseTool, SafetyClass, ToolError, ToolResult
28
+
29
+ __all__ = [
30
+ "EditFileTool",
31
+ "GrepTool",
32
+ "ListDirTool",
33
+ "ReadFileTool",
34
+ "WriteFileTool",
35
+ "FileSession",
36
+ "truncate_head_tail",
37
+ "apply_search_replace",
38
+ "MAX_REWRITE_LINES",
39
+ "MAX_READ_LINES",
40
+ ]
41
+
42
+ MAX_REWRITE_LINES = 200
43
+ MAX_READ_LINES = 2000
44
+ MAX_READ_BYTES = 512 * 1024
45
+
46
+
47
+ @dataclass
48
+ class FileSession:
49
+ """Per-session bookkeeping shared by the filesystem tools."""
50
+
51
+ repo_root: Path
52
+ policy: Policy
53
+ read: set[Path] = field(default_factory=set)
54
+ written: set[Path] = field(default_factory=set)
55
+ #: Set by `build_registry`; the notebook exec tool needs the run envelope
56
+ #: (timeouts, scrubbed env, scratch dir) that `ExecConfig` carries.
57
+ exec_cfg: Any = None
58
+ #: Set by `build_registry`: the shared store that applies the §3.5 size policy.
59
+ artifact_store: Any = None
60
+ #: Optional `(kind, payload, mime) -> None` renderer, injected by the notebook
61
+ #: UI. `None` means headless: artifacts are referenced, never drawn.
62
+ render: Any = None
63
+
64
+ def __post_init__(self) -> None:
65
+ self.repo_root = Path(self.repo_root).resolve()
66
+
67
+ def resolve(self, path: str) -> Path:
68
+ candidate = Path(path)
69
+ if not candidate.is_absolute():
70
+ candidate = self.repo_root / candidate
71
+ try:
72
+ return candidate.resolve()
73
+ except OSError as exc:
74
+ raise ToolError(f"cannot resolve path {path!r}: {exc}") from exc
75
+
76
+ def rel(self, path: Path) -> str:
77
+ """Repo-relative POSIX path. Colab is Linux; a stable separator keeps the
78
+ model's `path` arguments consistent across dev machines."""
79
+ try:
80
+ return path.relative_to(self.repo_root).as_posix()
81
+ except ValueError:
82
+ return path.as_posix()
83
+
84
+ def mark_read(self, path: Path) -> None:
85
+ self.read.add(path)
86
+
87
+ def mark_written(self, path: Path) -> None:
88
+ self.written.add(path)
89
+ self.read.add(path)
90
+
91
+ def was_read(self, path: Path) -> bool:
92
+ return path in self.read or path in self.written
93
+
94
+
95
+ def truncate_head_tail(text: str, *, max_lines: int = MAX_READ_LINES, start_line: int = 1) -> tuple[str, bool]:
96
+ """Head/tail truncation with line numbers; never a silent mid-file cut."""
97
+ lines = text.splitlines()
98
+ if len(lines) <= max_lines:
99
+ numbered = [f"{start_line + i:>6}\t{line}" for i, line in enumerate(lines)]
100
+ return "\n".join(numbered), False
101
+ head_n = max_lines * 6 // 10
102
+ tail_n = max_lines - head_n
103
+ head = lines[:head_n]
104
+ tail = lines[-tail_n:]
105
+ omitted = len(lines) - head_n - tail_n
106
+ out = [f"{start_line + i:>6}\t{line}" for i, line in enumerate(head)]
107
+ out.append(f" … [{omitted} lines omitted]")
108
+ out.extend(
109
+ f"{start_line + len(lines) - tail_n + i:>6}\t{line}" for i, line in enumerate(tail)
110
+ )
111
+ return "\n".join(out), True
112
+
113
+
114
+ def _normalize_ws(text: str) -> str:
115
+ return "\n".join(line.strip() for line in text.strip().splitlines())
116
+
117
+
118
+ def apply_search_replace(source: str, search: str, replace: str, *, path: str = "") -> tuple[str, int]:
119
+ """Apply one SEARCH/REPLACE block, returning new text and the 1-based line.
120
+
121
+ Exact match first; then a whitespace/indentation-insensitive fuzzy pass. Any
122
+ ambiguity is an error, never a guess.
123
+ """
124
+ if not search:
125
+ raise ToolError("the `search` block is empty; provide the exact text to replace.")
126
+
127
+ occurrences = source.count(search)
128
+ if occurrences == 1:
129
+ idx = source.index(search)
130
+ return source[:idx] + replace + source[idx + len(search) :], source[:idx].count("\n") + 1
131
+ if occurrences > 1:
132
+ starts = [m.start() for m in re.finditer(re.escape(search), source)]
133
+ lines = [source[:s].count("\n") + 1 for s in starts]
134
+ raise ToolError(
135
+ f"the `search` block matches {occurrences} times in {path or 'the file'} "
136
+ f"(lines {', '.join(map(str, lines))}). Add surrounding context so it is unique."
137
+ )
138
+
139
+ # Fuzzy pass: compare whitespace-normalized lines.
140
+ src_lines = source.splitlines(keepends=True)
141
+ search_norm = _normalize_ws(search)
142
+ search_len = len(search.strip().splitlines())
143
+ best: tuple[float, int, int] | None = None
144
+ for start in range(len(src_lines)):
145
+ window = "".join(src_lines[start : start + search_len])
146
+ if not window:
147
+ break
148
+ ratio = difflib.SequenceMatcher(None, search_norm, _normalize_ws(window)).ratio()
149
+ if best is None or ratio > best[0]:
150
+ best = (ratio, start, start + search_len)
151
+
152
+ if best is not None and best[0] >= 0.75:
153
+ ratio, start, end = best
154
+ region_lines = [
155
+ f"{i + 1:>6}\t{src_lines[i].rstrip()}" for i in range(start, min(end, len(src_lines)))
156
+ ]
157
+ raise ToolError(
158
+ f"no exact match in {path or 'the file'}, but a {ratio:.0%} similar region exists at "
159
+ f"lines {start + 1}-{end}. Re-issue the edit with this exact text:\n"
160
+ + "\n".join(region_lines)
161
+ )
162
+ raise ToolError(
163
+ f"the `search` block does not appear in {path or 'the file'}. "
164
+ "Read the file first and copy the text exactly, including indentation."
165
+ )
166
+
167
+
168
+ class _PolicyTool(BaseTool):
169
+ """Shared plumbing: a `FileSession`-backed resolver with jail checks."""
170
+
171
+ session: FileSession
172
+
173
+ def __init__(self, session: FileSession) -> None:
174
+ super().__init__(session)
175
+ self.session = session
176
+
177
+ def _read_path(self, path: str) -> Path:
178
+ resolved = self.session.resolve(path)
179
+ decision = self.session.policy.check_read(resolved)
180
+ if decision.verdict is Verdict.DENY:
181
+ raise ToolError(decision.reason)
182
+ return resolved
183
+
184
+ def _write_path(self, path: str) -> Path:
185
+ resolved = self.session.resolve(path)
186
+ decision = self.session.policy.check_write(resolved)
187
+ if decision.verdict is Verdict.DENY:
188
+ raise ToolError(decision.reason)
189
+ if decision.verdict is Verdict.ASK:
190
+ # Approvals are resolved by the loop before the tool runs; reaching
191
+ # here means the loop skipped the gate.
192
+ raise ToolError(decision.reason)
193
+ return resolved
194
+
195
+
196
+ class ReadFileTool(_PolicyTool):
197
+ name = "read_file"
198
+ description = (
199
+ "Read a text file from the repository. Returns line-numbered content. "
200
+ "Files over 2000 lines are head/tail truncated. Always read before editing."
201
+ )
202
+ safety = SafetyClass.READ
203
+ args = (
204
+ ArgSpec("path", "string", "Repo-relative path to the file."),
205
+ ArgSpec("start", "integer", "1-based first line to read.", required=False, default=1),
206
+ ArgSpec("end", "integer", "Last line to read (inclusive).", required=False, default=0),
207
+ )
208
+
209
+ def run(self, path: str, start: int = 1, end: int = 0) -> ToolResult:
210
+ resolved = self._read_path(path)
211
+ if not resolved.exists():
212
+ raise ToolError(f"{path} does not exist. Use list_dir to find the right path.")
213
+ if resolved.is_dir():
214
+ raise ToolError(f"{path} is a directory; use list_dir.")
215
+ if resolved.suffix.lower() == ".ipynb":
216
+ # JSON-notebook text is unreadable as line numbers and every byte of it
217
+ # is escaped source. Point at the tool that shows cells instead.
218
+ raise ToolError(
219
+ f"{path} is a notebook; use read_notebook to see its cells by id, "
220
+ "or edit_notebook to change one."
221
+ )
222
+ try:
223
+ raw = resolved.read_bytes()
224
+ except OSError as exc:
225
+ raise ToolError(f"cannot read {path}: {exc}") from exc
226
+ if b"\x00" in raw[:4096]:
227
+ raise ToolError(f"{path} looks binary; use a shell command or an artifact tool.")
228
+ if len(raw) > MAX_READ_BYTES:
229
+ raise ToolError(
230
+ f"{path} is {len(raw) / 1024:.0f} KB, over the {MAX_READ_BYTES // 1024} KB read cap. "
231
+ "Read a line range with start/end, or grep for what you need."
232
+ )
233
+ text = raw.decode("utf-8", errors="replace")
234
+ self.session.mark_read(resolved)
235
+
236
+ lines = text.splitlines()
237
+ offset = max(start, 1) - 1
238
+ if end and end >= start:
239
+ lines = lines[offset:end]
240
+ else:
241
+ text = "\n".join(lines[offset:])
242
+ body, truncated = truncate_head_tail(text, start_line=offset + 1)
243
+ return ToolResult.success(body, path=self.session.rel(resolved), truncated=truncated)
244
+ numbered = "\n".join(f"{offset + i + 1:>6}\t{ln}" for i, ln in enumerate(lines))
245
+ return ToolResult.success(numbered, path=self.session.rel(resolved))
246
+
247
+
248
+ class ListDirTool(_PolicyTool):
249
+ name = "list_dir"
250
+ description = "List a directory in the repository, one entry per line with sizes."
251
+ safety = SafetyClass.READ
252
+ args = (
253
+ ArgSpec("path", "string", "Repo-relative directory path.", required=False, default="."),
254
+ ArgSpec("depth", "integer", "Recursion depth (1-3).", required=False, default=1),
255
+ )
256
+
257
+ def run(self, path: str = ".", depth: int = 1) -> ToolResult:
258
+ resolved = self._read_path(path)
259
+ if not resolved.is_dir():
260
+ raise ToolError(f"{path} is not a directory.")
261
+ depth = max(1, min(depth, 3))
262
+ out: list[str] = []
263
+ self._walk(resolved, "", depth, out, cap=400)
264
+ self.session.mark_read(resolved)
265
+ if not out:
266
+ return ToolResult.success(f"{path} is empty.")
267
+ return ToolResult.success("\n".join(out), path=self.session.rel(resolved))
268
+
269
+ def _walk(self, root: Path, prefix: str, depth: int, out: list[str], cap: int) -> None:
270
+ if len(out) >= cap:
271
+ return
272
+ try:
273
+ entries = sorted(root.iterdir(), key=lambda p: (p.is_file(), p.name))
274
+ except OSError as exc:
275
+ out.append(f"{prefix}[unreadable: {exc}]")
276
+ return
277
+ for entry in entries:
278
+ if len(out) >= cap:
279
+ out.append("… [listing truncated]")
280
+ return
281
+ if entry.name == ".git":
282
+ continue
283
+ if entry.is_dir():
284
+ out.append(f"{prefix}{entry.name}/")
285
+ if depth > 1:
286
+ self._walk(entry, prefix + " ", depth - 1, out, cap)
287
+ else:
288
+ try:
289
+ size = entry.stat().st_size
290
+ except OSError:
291
+ size = -1
292
+ out.append(f"{prefix}{entry.name} ({_human(size)})")
293
+
294
+
295
+ class GrepTool(_PolicyTool):
296
+ name = "grep"
297
+ description = (
298
+ "Search file contents with a Python regular expression. Returns matching "
299
+ "lines with line numbers. Use this instead of reading whole files."
300
+ )
301
+ safety = SafetyClass.READ
302
+ args = (
303
+ ArgSpec("pattern", "string", "Python regular expression."),
304
+ ArgSpec("path", "string", "File or directory to search.", required=False, default="."),
305
+ ArgSpec("glob", "string", "Only search files matching this glob, e.g. '*.py'.", required=False, default=""),
306
+ ArgSpec("max_matches", "integer", "Maximum matching lines to return.", required=False, default=80),
307
+ )
308
+
309
+ def run(self, pattern: str, path: str = ".", glob: str = "", max_matches: int = 80) -> ToolResult:
310
+ try:
311
+ regex = re.compile(pattern)
312
+ except re.error as exc:
313
+ raise ToolError(f"invalid regex {pattern!r}: {exc}") from exc
314
+ resolved = self._read_path(path)
315
+ max_matches = max(1, min(max_matches, 300))
316
+ files = [resolved] if resolved.is_file() else _iter_files(resolved, glob)
317
+
318
+ hits: list[str] = []
319
+ scanned = 0
320
+ for file in files:
321
+ if len(hits) >= max_matches:
322
+ break
323
+ decision = self.session.policy.check_read(file)
324
+ if decision.verdict is not Verdict.ALLOW:
325
+ continue
326
+ scanned += 1
327
+ try:
328
+ with file.open("r", encoding="utf-8", errors="replace") as fh:
329
+ for lineno, line in enumerate(fh, 1):
330
+ if regex.search(line):
331
+ rel = self.session.rel(file)
332
+ hits.append(f"{rel}:{lineno}: {line.rstrip()[:300]}")
333
+ if len(hits) >= max_matches:
334
+ break
335
+ except OSError:
336
+ continue
337
+ self.session.mark_read(resolved)
338
+ if not hits:
339
+ return ToolResult.success(
340
+ f"no matches for {pattern!r} in {path} ({scanned} files scanned)", matches=0
341
+ )
342
+ body = "\n".join(hits)
343
+ if len(hits) >= max_matches:
344
+ body += f"\n… [stopped at {max_matches} matches]"
345
+ return ToolResult.success(body, matches=len(hits))
346
+
347
+
348
+ class EditFileTool(_PolicyTool):
349
+ name = "edit_file"
350
+ description = (
351
+ "Replace one exact, unique block of text in an existing file (SEARCH/REPLACE). "
352
+ "The file must have been read this session. Prefer this over write_file."
353
+ )
354
+ safety = SafetyClass.WRITE
355
+ args = (
356
+ ArgSpec("path", "string", "Repo-relative path to edit."),
357
+ ArgSpec("search", "string", "Exact existing text to replace. Must match exactly once."),
358
+ ArgSpec("replace", "string", "Replacement text."),
359
+ ArgSpec("cite", "string", "Optional file:line hint you are anchoring on (advisory).", required=False, default=""),
360
+ )
361
+
362
+ def run(self, path: str, search: str, replace: str, cite: str = "") -> ToolResult:
363
+ resolved = self._write_path(path)
364
+ if not self.session.was_read(resolved):
365
+ raise ToolError(
366
+ f"{path} has not been read this session. Call read_file on it first, "
367
+ "then re-issue the edit with the exact text."
368
+ )
369
+ if not resolved.is_file():
370
+ raise ToolError(f"{path} does not exist; use write_file to create it.")
371
+ try:
372
+ original = resolved.read_text(encoding="utf-8")
373
+ except OSError as exc:
374
+ raise ToolError(f"cannot read {path}: {exc}") from exc
375
+
376
+ updated, line = apply_search_replace(original, search, replace, path=path)
377
+ syntax_error = _syntax_check(resolved, updated)
378
+ if syntax_error:
379
+ raise ToolError(
380
+ f"the edit would leave {path} unparseable ({syntax_error}). "
381
+ "Re-check the indentation and syntax in your `replace` block."
382
+ )
383
+ try:
384
+ resolved.write_text(updated, encoding="utf-8")
385
+ except OSError as exc:
386
+ raise ToolError(f"cannot write {path}: {exc}") from exc
387
+ self.session.mark_written(resolved)
388
+
389
+ diff = _unified_diff(original, updated, self.session.rel(resolved))
390
+ return ToolResult.success(
391
+ f"edited {path} at line {line}\n{diff}",
392
+ path=self.session.rel(resolved),
393
+ diff=diff,
394
+ line=line,
395
+ added=max(0, diff.count("\n+") - diff.count("\n+++")),
396
+ )
397
+
398
+
399
+ class WriteFileTool(_PolicyTool):
400
+ name = "write_file"
401
+ description = (
402
+ "Create a new file, or fully rewrite a file under 200 lines. "
403
+ "Large rewrites are refused; use edit_file for existing files."
404
+ )
405
+ safety = SafetyClass.WRITE
406
+ args = (
407
+ ArgSpec("path", "string", "Repo-relative path to write."),
408
+ ArgSpec("content", "string", "Full file content."),
409
+ )
410
+
411
+ def run(self, path: str, content: str) -> ToolResult:
412
+ resolved = self._write_path(path)
413
+ exists = resolved.exists()
414
+ if exists:
415
+ if not self.session.was_read(resolved):
416
+ raise ToolError(
417
+ f"{path} already exists and has not been read this session. "
418
+ "Read it and use edit_file, or read it first before rewriting."
419
+ )
420
+ try:
421
+ current_lines = len(resolved.read_text(encoding="utf-8").splitlines())
422
+ except OSError as exc:
423
+ raise ToolError(f"cannot read {path}: {exc}") from exc
424
+ if current_lines > MAX_REWRITE_LINES:
425
+ raise ToolError(
426
+ f"{path} is {current_lines} lines; full rewrites are only allowed under "
427
+ f"{MAX_REWRITE_LINES}. Use edit_file."
428
+ )
429
+ syntax_error = _syntax_check(resolved, content)
430
+ if syntax_error and not syntax_error.startswith("unknown"):
431
+ raise ToolError(f"{path} would not parse ({syntax_error}); fix the content first.")
432
+
433
+ try:
434
+ resolved.parent.mkdir(parents=True, exist_ok=True)
435
+ resolved.write_text(content, encoding="utf-8")
436
+ except OSError as exc:
437
+ raise ToolError(f"cannot write {path}: {exc}") from exc
438
+ self.session.mark_written(resolved)
439
+ return ToolResult.success(
440
+ f"wrote {path} ({len(content.splitlines())} lines)",
441
+ path=self.session.rel(resolved),
442
+ created=not exists,
443
+ )
444
+
445
+
446
+ # ----------------------------------------------------------------- helpers
447
+
448
+
449
+ def _iter_files(root: Path, glob: str) -> list[Path]:
450
+ pattern = glob or "*"
451
+ if root.is_file():
452
+ return [root]
453
+ return [p for p in sorted(root.rglob(pattern)) if p.is_file() and ".git" not in p.parts]
454
+
455
+
456
+ def _syntax_check(path: Path, text: str) -> str:
457
+ """Parse the file if we know how. Returns an error string, or '' if fine."""
458
+ suffix = path.suffix.lower()
459
+ if suffix == ".py":
460
+ import ast
461
+
462
+ try:
463
+ ast.parse(text)
464
+ except SyntaxError as exc:
465
+ return f"{exc.msg} at line {exc.lineno}"
466
+ return ""
467
+ if suffix == ".json":
468
+ import json
469
+
470
+ try:
471
+ json.loads(text)
472
+ except json.JSONDecodeError as exc:
473
+ return f"{exc.msg} at line {exc.lineno}"
474
+ return ""
475
+ if suffix in {".toml", ".tml"}:
476
+ try:
477
+ from ..config import tomllib
478
+ except ImportError: # pragma: no cover
479
+ return ""
480
+ try:
481
+ tomllib.loads(text)
482
+ except Exception as exc: # noqa: BLE001 - tomllib raises variants by version
483
+ return str(exc)
484
+ return ""
485
+ return "" # unknown language: the configured linter is the gate (FR-VER-4)
486
+
487
+
488
+ def _unified_diff(before: str, after: str, name: str, context: int = 3) -> str:
489
+ diff = difflib.unified_diff(
490
+ before.splitlines(),
491
+ after.splitlines(),
492
+ fromfile=f"a/{name}",
493
+ tofile=f"b/{name}",
494
+ n=context,
495
+ lineterm="",
496
+ )
497
+ return "\n".join(diff)
498
+
499
+
500
+ def _human(size: int) -> str:
501
+ if size < 0:
502
+ return "?"
503
+ for unit in ("B", "KB", "MB", "GB"):
504
+ if size < 1024 or unit == "GB":
505
+ return f"{size:.0f} {unit}" if unit == "B" else f"{size:.1f} {unit}"
506
+ size /= 1024.0
507
+ return f"{size:.1f} GB"