td-task 0.2.0__tar.gz → 0.2.2__tar.gz
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.
- {td_task-0.2.0 → td_task-0.2.2}/.github/workflows/publish.yml +9 -6
- {td_task-0.2.0 → td_task-0.2.2}/PKG-INFO +12 -1
- {td_task-0.2.0 → td_task-0.2.2}/README.md +11 -0
- {td_task-0.2.0 → td_task-0.2.2}/pyproject.toml +1 -1
- {td_task-0.2.0 → td_task-0.2.2}/src/td/CHANGELOG.md +9 -0
- {td_task-0.2.0 → td_task-0.2.2}/src/td/__main__.py +62 -3
- {td_task-0.2.0 → td_task-0.2.2}/src/td/tui.py +89 -20
- {td_task-0.2.0 → td_task-0.2.2}/tests/test_tui_notes.py +48 -0
- td_task-0.2.2/tests/test_update.py +141 -0
- {td_task-0.2.0 → td_task-0.2.2}/uv.lock +1 -1
- {td_task-0.2.0 → td_task-0.2.2}/.env.enc +0 -0
- {td_task-0.2.0 → td_task-0.2.2}/.gitignore +0 -0
- {td_task-0.2.0 → td_task-0.2.2}/.python-version +0 -0
- {td_task-0.2.0 → td_task-0.2.2}/assets/demo.gif +0 -0
- {td_task-0.2.0 → td_task-0.2.2}/demo.tape +0 -0
- {td_task-0.2.0 → td_task-0.2.2}/src/td/__init__.py +0 -0
- {td_task-0.2.0 → td_task-0.2.2}/src/td/db.py +0 -0
- {td_task-0.2.0 → td_task-0.2.2}/src/td/terminal.py +0 -0
- {td_task-0.2.0 → td_task-0.2.2}/tests/test_cli_notes.py +0 -0
- {td_task-0.2.0 → td_task-0.2.2}/tests/test_notes.py +0 -0
|
@@ -8,7 +8,13 @@ on:
|
|
|
8
8
|
|
|
9
9
|
jobs:
|
|
10
10
|
deploy:
|
|
11
|
+
name: Publish distribution to PyPI
|
|
11
12
|
runs-on: ubuntu-latest
|
|
13
|
+
environment:
|
|
14
|
+
name: pypi
|
|
15
|
+
url: https://pypi.org/project/td-task/
|
|
16
|
+
permissions:
|
|
17
|
+
id-token: write
|
|
12
18
|
steps:
|
|
13
19
|
- name: Checkout code
|
|
14
20
|
uses: actions/checkout@v4
|
|
@@ -16,9 +22,6 @@ jobs:
|
|
|
16
22
|
- name: Install uv
|
|
17
23
|
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
|
18
24
|
|
|
19
|
-
- name: Install Doppler CLI
|
|
20
|
-
uses: dopplerhq/cli-action@v3
|
|
21
|
-
|
|
22
25
|
- name: Set up Python
|
|
23
26
|
uses: actions/setup-python@v5
|
|
24
27
|
with:
|
|
@@ -28,6 +31,6 @@ jobs:
|
|
|
28
31
|
run: uv build
|
|
29
32
|
|
|
30
33
|
- name: Publish to PyPI
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
+
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1
|
|
35
|
+
with:
|
|
36
|
+
skip-existing: true
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: td-task
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: Fast TUI and CLI manager for tasks and Markdown notes
|
|
5
5
|
Requires-Python: >=3.14
|
|
6
6
|
Requires-Dist: cryptography>=42.0.0
|
|
@@ -28,6 +28,17 @@ Or with pip:
|
|
|
28
28
|
pip install td-task
|
|
29
29
|
```
|
|
30
30
|
|
|
31
|
+
## Local publishing environment
|
|
32
|
+
|
|
33
|
+
PyPI credentials live in the ignored `.env` file. The encrypted backup is
|
|
34
|
+
`.env.enc`.
|
|
35
|
+
|
|
36
|
+
```nu
|
|
37
|
+
^age --decrypt .env.enc | save --force .env
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Never print decrypted secrets or commit `.env`.
|
|
41
|
+
|
|
31
42
|
Run directly without installing:
|
|
32
43
|
|
|
33
44
|
```bash
|
|
@@ -17,6 +17,17 @@ Or with pip:
|
|
|
17
17
|
pip install td-task
|
|
18
18
|
```
|
|
19
19
|
|
|
20
|
+
## Local publishing environment
|
|
21
|
+
|
|
22
|
+
PyPI credentials live in the ignored `.env` file. The encrypted backup is
|
|
23
|
+
`.env.enc`.
|
|
24
|
+
|
|
25
|
+
```nu
|
|
26
|
+
^age --decrypt .env.enc | save --force .env
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Never print decrypted secrets or commit `.env`.
|
|
30
|
+
|
|
20
31
|
Run directly without installing:
|
|
21
32
|
|
|
22
33
|
```bash
|
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.2.2] - 2026-08-05
|
|
4
|
+
- Fix `td update` silently reporting "already up-to-date" on installs made from a local checkout.
|
|
5
|
+
- Pull the source checkout with `git pull --ff-only` and force a rebuild before upgrading such installs.
|
|
6
|
+
- Abort the update instead of rebuilding stale code when the pull fails.
|
|
7
|
+
|
|
8
|
+
## [0.2.1] - 2026-08-04
|
|
9
|
+
- Soft-wrap long note lines to terminal width without changing stored Markdown.
|
|
10
|
+
- Keep Markdown preview styling across wrapped rows and scroll with the active cursor.
|
|
11
|
+
|
|
3
12
|
## [0.2.0] - 2026-08-03
|
|
4
13
|
- Add task-backed Markdown notes with a full-screen editor and live line preview.
|
|
5
14
|
- Add `A` to create notes, `e`/`Enter` to edit note bodies, and `E` to rename notes.
|
|
@@ -393,13 +393,72 @@ def _run_list(list_name: str) -> None:
|
|
|
393
393
|
console.print(Text("─" * width, style="dim"))
|
|
394
394
|
|
|
395
395
|
|
|
396
|
+
def _source_checkout() -> str | None:
|
|
397
|
+
"""Return the git checkout td was installed from, or None for a PyPI install."""
|
|
398
|
+
import subprocess
|
|
399
|
+
import tomllib
|
|
400
|
+
|
|
401
|
+
try:
|
|
402
|
+
result = subprocess.run(
|
|
403
|
+
["uv", "tool", "dir"], capture_output=True, text=True, timeout=10,
|
|
404
|
+
)
|
|
405
|
+
if result.returncode != 0:
|
|
406
|
+
return None
|
|
407
|
+
receipt = os.path.join(result.stdout.strip(), "td-task", "uv-receipt.toml")
|
|
408
|
+
with open(receipt, "rb") as f:
|
|
409
|
+
data = tomllib.load(f)
|
|
410
|
+
except (OSError, ValueError, subprocess.SubprocessError):
|
|
411
|
+
return None
|
|
412
|
+
|
|
413
|
+
for requirement in data.get("tool", {}).get("requirements", []):
|
|
414
|
+
directory = requirement.get("directory")
|
|
415
|
+
if directory and os.path.isdir(os.path.join(directory, ".git")):
|
|
416
|
+
return directory
|
|
417
|
+
return None
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _source_version(source_dir: str) -> str | None:
|
|
421
|
+
"""Read the version declared by the source checkout's pyproject.toml."""
|
|
422
|
+
import tomllib
|
|
423
|
+
|
|
424
|
+
try:
|
|
425
|
+
with open(os.path.join(source_dir, "pyproject.toml"), "rb") as f:
|
|
426
|
+
return tomllib.load(f).get("project", {}).get("version")
|
|
427
|
+
except (OSError, ValueError):
|
|
428
|
+
return None
|
|
429
|
+
|
|
430
|
+
|
|
396
431
|
def _run_update() -> None:
|
|
397
|
-
"""Update td to the latest version from
|
|
432
|
+
"""Update td to the latest version, from the local checkout when installed from one."""
|
|
398
433
|
import subprocess
|
|
434
|
+
from importlib.metadata import version as _pkg_version
|
|
435
|
+
|
|
436
|
+
upgrade_cmd = ["uv", "tool", "upgrade", "td-task"]
|
|
437
|
+
source_dir = _source_checkout()
|
|
438
|
+
|
|
439
|
+
if source_dir:
|
|
440
|
+
print(f"Pulling latest source from {source_dir}...")
|
|
441
|
+
pull = subprocess.run(
|
|
442
|
+
["git", "-C", source_dir, "pull", "--ff-only"],
|
|
443
|
+
capture_output=True, text=True, timeout=60,
|
|
444
|
+
)
|
|
445
|
+
if pull.returncode != 0:
|
|
446
|
+
print(f"✗ git pull failed: {pull.stderr.strip()}")
|
|
447
|
+
sys.exit(1)
|
|
448
|
+
try:
|
|
449
|
+
installed = _pkg_version("td-task")
|
|
450
|
+
except Exception:
|
|
451
|
+
installed = None
|
|
452
|
+
if installed and _source_version(source_dir) == installed:
|
|
453
|
+
print("already up-to-date.")
|
|
454
|
+
return
|
|
455
|
+
# A path install keeps resolving to the same requirement, so uv reports
|
|
456
|
+
# nothing to upgrade unless the rebuild is forced.
|
|
457
|
+
upgrade_cmd.append("--reinstall")
|
|
458
|
+
|
|
399
459
|
print("Updating td...")
|
|
400
460
|
result = subprocess.run(
|
|
401
|
-
|
|
402
|
-
capture_output=True, text=True, timeout=60,
|
|
461
|
+
upgrade_cmd, capture_output=True, text=True, timeout=300,
|
|
403
462
|
)
|
|
404
463
|
if result.returncode == 0:
|
|
405
464
|
output = (result.stdout + result.stderr).strip()
|
|
@@ -573,6 +573,78 @@ def _bullet_line_parts(source: str) -> tuple[str, str, str] | None:
|
|
|
573
573
|
return match.group(1), match.group(2), match.group(3)
|
|
574
574
|
|
|
575
575
|
|
|
576
|
+
def _wrap_editor_text(source: Text, width: int) -> list[Text]:
|
|
577
|
+
"""Soft-wrap styled text without changing or dropping source characters."""
|
|
578
|
+
from rich.cells import cell_len
|
|
579
|
+
|
|
580
|
+
width = max(1, width)
|
|
581
|
+
plain = source.plain
|
|
582
|
+
if not plain:
|
|
583
|
+
return [Text()]
|
|
584
|
+
|
|
585
|
+
offsets: list[tuple[int, int]] = []
|
|
586
|
+
start = 0
|
|
587
|
+
while start < len(plain):
|
|
588
|
+
end = start
|
|
589
|
+
used = 0
|
|
590
|
+
last_break: int | None = None
|
|
591
|
+
has_content = False
|
|
592
|
+
|
|
593
|
+
while end < len(plain):
|
|
594
|
+
char_width = cell_len(plain[end])
|
|
595
|
+
if end > start and used + char_width > width:
|
|
596
|
+
break
|
|
597
|
+
used += char_width
|
|
598
|
+
end += 1
|
|
599
|
+
if plain[end - 1].isspace() and has_content:
|
|
600
|
+
last_break = end
|
|
601
|
+
elif not plain[end - 1].isspace():
|
|
602
|
+
has_content = True
|
|
603
|
+
if used >= width:
|
|
604
|
+
break
|
|
605
|
+
|
|
606
|
+
if end < len(plain) and last_break is not None and last_break > start:
|
|
607
|
+
end = last_break
|
|
608
|
+
if end == start:
|
|
609
|
+
end += 1
|
|
610
|
+
|
|
611
|
+
offsets.append((start, end))
|
|
612
|
+
start = end
|
|
613
|
+
|
|
614
|
+
return [source[start:end] for start, end in offsets]
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def _note_editor_visual_rows(
|
|
618
|
+
lines: list[str],
|
|
619
|
+
active_row: int,
|
|
620
|
+
column: int,
|
|
621
|
+
content_width: int,
|
|
622
|
+
) -> tuple[list[tuple[int, int, Text]], int]:
|
|
623
|
+
"""Build wrapped display rows and return the active cursor row index."""
|
|
624
|
+
visual_rows: list[tuple[int, int, Text]] = []
|
|
625
|
+
cursor_visual_row = 0
|
|
626
|
+
|
|
627
|
+
for line_index, line in enumerate(lines):
|
|
628
|
+
if line_index == active_row:
|
|
629
|
+
rendered = Text(line, style="cyan bold")
|
|
630
|
+
if column < len(line):
|
|
631
|
+
rendered.stylize("reverse cyan bold", column, column + 1)
|
|
632
|
+
else:
|
|
633
|
+
rendered.append(" ", style="reverse cyan bold")
|
|
634
|
+
wrapped = _wrap_editor_text(rendered, content_width)
|
|
635
|
+
else:
|
|
636
|
+
wrapped = _wrap_editor_text(_markdown_preview_line(line), content_width)
|
|
637
|
+
|
|
638
|
+
for segment_index, segment in enumerate(wrapped):
|
|
639
|
+
visual_rows.append((line_index, segment_index, segment))
|
|
640
|
+
if line_index == active_row and any(
|
|
641
|
+
"reverse" in str(span.style) for span in segment.spans
|
|
642
|
+
):
|
|
643
|
+
cursor_visual_row = len(visual_rows) - 1
|
|
644
|
+
|
|
645
|
+
return visual_rows, cursor_visual_row
|
|
646
|
+
|
|
647
|
+
|
|
576
648
|
def _render_note_editor(
|
|
577
649
|
title: str,
|
|
578
650
|
lines: list[str],
|
|
@@ -592,28 +664,21 @@ def _render_note_editor(
|
|
|
592
664
|
console.print(Text("─" * width, style="dim"), end="\033[K\n")
|
|
593
665
|
|
|
594
666
|
visible_rows = max(3, height - 6)
|
|
595
|
-
end = min(len(lines), scroll + visible_rows)
|
|
596
667
|
number_width = max(2, len(str(len(lines))))
|
|
597
668
|
content_width = max(10, width - number_width - 5)
|
|
669
|
+
visual_rows, _ = _note_editor_visual_rows(lines, row, column, content_width)
|
|
670
|
+
visible = visual_rows[scroll:scroll + visible_rows]
|
|
598
671
|
|
|
599
|
-
for line_index in
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
output = Text(prefix, style="cyan bold" if line_index == row else "dim")
|
|
603
|
-
if line_index == row:
|
|
604
|
-
offset = max(0, column - content_width + 1)
|
|
605
|
-
visible = line[offset:offset + content_width]
|
|
606
|
-
visible_column = column - offset
|
|
607
|
-
output.append(visible[:visible_column], style="cyan bold")
|
|
608
|
-
char_under = visible[visible_column] if visible_column < len(visible) else " "
|
|
609
|
-
output.append(char_under, style="reverse cyan bold")
|
|
610
|
-
if visible_column < len(visible):
|
|
611
|
-
output.append(visible[visible_column + 1:], style="cyan bold")
|
|
672
|
+
for line_index, segment_index, segment in visible:
|
|
673
|
+
if segment_index == 0:
|
|
674
|
+
prefix = f"{'▸' if line_index == row else ' '} {line_index + 1:>{number_width}} │ "
|
|
612
675
|
else:
|
|
613
|
-
|
|
676
|
+
prefix = f" {'':>{number_width}} │ "
|
|
677
|
+
output = Text(prefix, style="cyan bold" if line_index == row else "dim")
|
|
678
|
+
output.append(segment)
|
|
614
679
|
console.print(output, end="\033[K\n", overflow="crop", no_wrap=True)
|
|
615
680
|
|
|
616
|
-
for _ in range(visible_rows - (
|
|
681
|
+
for _ in range(visible_rows - len(visible)):
|
|
617
682
|
console.print(end="\033[K\n")
|
|
618
683
|
|
|
619
684
|
footer = " Esc:save & close │ Ctrl+S:save │ Enter:new line │ arrows:move"
|
|
@@ -643,10 +708,14 @@ def _run_note_editor(note_id: int) -> None:
|
|
|
643
708
|
|
|
644
709
|
while True:
|
|
645
710
|
visible_rows = max(3, (console.height or 24) - 6)
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
711
|
+
width = max(30, console.width or 80)
|
|
712
|
+
number_width = max(2, len(str(len(lines))))
|
|
713
|
+
content_width = max(10, width - number_width - 5)
|
|
714
|
+
_, cursor_visual_row = _note_editor_visual_rows(lines, row, column, content_width)
|
|
715
|
+
if cursor_visual_row < scroll:
|
|
716
|
+
scroll = cursor_visual_row
|
|
717
|
+
elif cursor_visual_row >= scroll + visible_rows:
|
|
718
|
+
scroll = cursor_visual_row - visible_rows + 1
|
|
650
719
|
_render_note_editor(note["title"], lines, row, column, scroll, status_msg)
|
|
651
720
|
status_msg = ""
|
|
652
721
|
key = term.read_key()
|
|
@@ -6,7 +6,9 @@ import unittest
|
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
from unittest.mock import patch
|
|
8
8
|
|
|
9
|
+
from rich.cells import cell_len
|
|
9
10
|
from rich.console import Console
|
|
11
|
+
from rich.text import Text
|
|
10
12
|
|
|
11
13
|
from td import db, terminal, tui
|
|
12
14
|
|
|
@@ -100,6 +102,52 @@ class NoteTuiTests(unittest.TestCase):
|
|
|
100
102
|
self.assertIn("bold", str(inline.spans[0].style))
|
|
101
103
|
self.assertIn("italic", str(inline.spans[1].style))
|
|
102
104
|
|
|
105
|
+
def test_note_editor_wrap_preserves_every_character(self) -> None:
|
|
106
|
+
source = Text("alpha beta gamma-supercalifragilistic", style="bold")
|
|
107
|
+
wrapped = tui._wrap_editor_text(source, 8)
|
|
108
|
+
|
|
109
|
+
self.assertEqual("".join(part.plain for part in wrapped), source.plain)
|
|
110
|
+
self.assertTrue(all(cell_len(part.plain) <= 8 for part in wrapped))
|
|
111
|
+
self.assertTrue(all("bold" in str(part.style) for part in wrapped))
|
|
112
|
+
|
|
113
|
+
def test_note_editor_cursor_follows_wrapped_line(self) -> None:
|
|
114
|
+
line = "one two three four five six"
|
|
115
|
+
visual_rows, cursor_row = tui._note_editor_visual_rows(
|
|
116
|
+
[line], 0, len(line), 7
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
self.assertGreater(len(visual_rows), 1)
|
|
120
|
+
self.assertEqual(visual_rows[cursor_row][0], 0)
|
|
121
|
+
self.assertTrue(any(
|
|
122
|
+
"reverse" in str(span.style)
|
|
123
|
+
for span in visual_rows[cursor_row][2].spans
|
|
124
|
+
))
|
|
125
|
+
|
|
126
|
+
def test_note_editor_scroll_tracks_wrapped_cursor(self) -> None:
|
|
127
|
+
note = db.add_note("Wrapping")
|
|
128
|
+
assert note is not None
|
|
129
|
+
db.update_note_content(note["id"], "word " * 40)
|
|
130
|
+
scroll_positions: list[int] = []
|
|
131
|
+
test_console = Console(file=io.StringIO(), width=30, height=10, force_terminal=False)
|
|
132
|
+
keys = iter([terminal.KEY_END, terminal.KEY_ESC])
|
|
133
|
+
|
|
134
|
+
with (
|
|
135
|
+
patch.object(tui, "console", test_console),
|
|
136
|
+
patch.object(terminal, "read_key", side_effect=lambda: next(keys)),
|
|
137
|
+
patch.object(terminal, "clear_screen"),
|
|
138
|
+
patch.object(
|
|
139
|
+
tui,
|
|
140
|
+
"_render_note_editor",
|
|
141
|
+
side_effect=lambda _title, _lines, _row, _column, scroll, _status: (
|
|
142
|
+
scroll_positions.append(scroll)
|
|
143
|
+
),
|
|
144
|
+
),
|
|
145
|
+
):
|
|
146
|
+
tui._run_note_editor(note["id"])
|
|
147
|
+
|
|
148
|
+
self.assertEqual(scroll_positions[0], 0)
|
|
149
|
+
self.assertGreater(scroll_positions[-1], 0)
|
|
150
|
+
|
|
103
151
|
def test_alt_arrows_move_editor_lines(self) -> None:
|
|
104
152
|
note = db.add_note("Movement")
|
|
105
153
|
assert note is not None
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
import subprocess
|
|
5
|
+
import tempfile
|
|
6
|
+
import unittest
|
|
7
|
+
from contextlib import redirect_stdout
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from unittest.mock import patch
|
|
10
|
+
|
|
11
|
+
from td.__main__ import _run_update, _source_checkout, _source_version
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _completed(args, returncode=0, stdout="", stderr=""):
|
|
15
|
+
return subprocess.CompletedProcess(args, returncode, stdout, stderr)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SourceCheckoutTests(unittest.TestCase):
|
|
19
|
+
def setUp(self) -> None:
|
|
20
|
+
self.temp_dir = tempfile.TemporaryDirectory()
|
|
21
|
+
self.root = Path(self.temp_dir.name)
|
|
22
|
+
self.checkout = self.root / "src-checkout"
|
|
23
|
+
(self.checkout / ".git").mkdir(parents=True)
|
|
24
|
+
self.tool_dir = self.root / "tools"
|
|
25
|
+
(self.tool_dir / "td-task").mkdir(parents=True)
|
|
26
|
+
|
|
27
|
+
def tearDown(self) -> None:
|
|
28
|
+
self.temp_dir.cleanup()
|
|
29
|
+
|
|
30
|
+
def _write_receipt(self, body: str) -> None:
|
|
31
|
+
(self.tool_dir / "td-task" / "uv-receipt.toml").write_text(body, encoding="utf-8")
|
|
32
|
+
|
|
33
|
+
def _tool_dir_call(self, *args, **kwargs):
|
|
34
|
+
return _completed(args, stdout=f"{self.tool_dir}\n")
|
|
35
|
+
|
|
36
|
+
def test_detects_directory_install(self) -> None:
|
|
37
|
+
self._write_receipt(
|
|
38
|
+
f'[tool]\nrequirements = [{{ name = "td-task", directory = "{self.checkout}" }}]\n'
|
|
39
|
+
)
|
|
40
|
+
with patch("subprocess.run", side_effect=self._tool_dir_call):
|
|
41
|
+
self.assertEqual(_source_checkout(), str(self.checkout))
|
|
42
|
+
|
|
43
|
+
def test_pypi_install_has_no_checkout(self) -> None:
|
|
44
|
+
self._write_receipt('[tool]\nrequirements = [{ name = "td-task" }]\n')
|
|
45
|
+
with patch("subprocess.run", side_effect=self._tool_dir_call):
|
|
46
|
+
self.assertIsNone(_source_checkout())
|
|
47
|
+
|
|
48
|
+
def test_directory_without_git_is_ignored(self) -> None:
|
|
49
|
+
plain = self.root / "not-a-repo"
|
|
50
|
+
plain.mkdir()
|
|
51
|
+
self._write_receipt(
|
|
52
|
+
f'[tool]\nrequirements = [{{ name = "td-task", directory = "{plain}" }}]\n'
|
|
53
|
+
)
|
|
54
|
+
with patch("subprocess.run", side_effect=self._tool_dir_call):
|
|
55
|
+
self.assertIsNone(_source_checkout())
|
|
56
|
+
|
|
57
|
+
def test_missing_receipt_returns_none(self) -> None:
|
|
58
|
+
with patch("subprocess.run", side_effect=self._tool_dir_call):
|
|
59
|
+
self.assertIsNone(_source_checkout())
|
|
60
|
+
|
|
61
|
+
def test_source_version_read_from_pyproject(self) -> None:
|
|
62
|
+
(self.checkout / "pyproject.toml").write_text(
|
|
63
|
+
'[project]\nname = "td-task"\nversion = "9.9.9"\n', encoding="utf-8"
|
|
64
|
+
)
|
|
65
|
+
self.assertEqual(_source_version(str(self.checkout)), "9.9.9")
|
|
66
|
+
|
|
67
|
+
def test_source_version_missing_pyproject(self) -> None:
|
|
68
|
+
self.assertIsNone(_source_version(str(self.checkout)))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class RunUpdateTests(unittest.TestCase):
|
|
72
|
+
def test_source_install_pulls_before_rebuilding(self) -> None:
|
|
73
|
+
calls = []
|
|
74
|
+
|
|
75
|
+
def fake_run(args, **kwargs):
|
|
76
|
+
calls.append(args)
|
|
77
|
+
return _completed(args)
|
|
78
|
+
|
|
79
|
+
with patch("td.__main__._source_checkout", return_value="/repo"), \
|
|
80
|
+
patch("td.__main__._source_version", return_value="0.3.0"), \
|
|
81
|
+
patch("td.__main__._print_changelog"), \
|
|
82
|
+
patch("importlib.metadata.version", return_value="0.2.9"), \
|
|
83
|
+
patch("subprocess.run", side_effect=fake_run), \
|
|
84
|
+
redirect_stdout(io.StringIO()):
|
|
85
|
+
_run_update()
|
|
86
|
+
|
|
87
|
+
self.assertEqual(calls[0], ["git", "-C", "/repo", "pull", "--ff-only"])
|
|
88
|
+
self.assertEqual(calls[1], ["uv", "tool", "upgrade", "td-task", "--reinstall"])
|
|
89
|
+
|
|
90
|
+
def test_source_install_skips_rebuild_when_current(self) -> None:
|
|
91
|
+
calls = []
|
|
92
|
+
|
|
93
|
+
def fake_run(args, **kwargs):
|
|
94
|
+
calls.append(args)
|
|
95
|
+
return _completed(args)
|
|
96
|
+
|
|
97
|
+
buffer = io.StringIO()
|
|
98
|
+
with patch("td.__main__._source_checkout", return_value="/repo"), \
|
|
99
|
+
patch("td.__main__._source_version", return_value="0.2.9"), \
|
|
100
|
+
patch("importlib.metadata.version", return_value="0.2.9"), \
|
|
101
|
+
patch("subprocess.run", side_effect=fake_run), \
|
|
102
|
+
redirect_stdout(buffer):
|
|
103
|
+
_run_update()
|
|
104
|
+
|
|
105
|
+
self.assertEqual(calls, [["git", "-C", "/repo", "pull", "--ff-only"]])
|
|
106
|
+
self.assertIn("already up-to-date", buffer.getvalue())
|
|
107
|
+
|
|
108
|
+
def test_failed_pull_aborts_without_rebuilding(self) -> None:
|
|
109
|
+
calls = []
|
|
110
|
+
|
|
111
|
+
def fake_run(args, **kwargs):
|
|
112
|
+
calls.append(args)
|
|
113
|
+
return _completed(args, returncode=1, stderr="divergent branches")
|
|
114
|
+
|
|
115
|
+
buffer = io.StringIO()
|
|
116
|
+
with patch("td.__main__._source_checkout", return_value="/repo"), \
|
|
117
|
+
patch("subprocess.run", side_effect=fake_run), \
|
|
118
|
+
redirect_stdout(buffer):
|
|
119
|
+
with self.assertRaises(SystemExit):
|
|
120
|
+
_run_update()
|
|
121
|
+
|
|
122
|
+
self.assertEqual(calls, [["git", "-C", "/repo", "pull", "--ff-only"]])
|
|
123
|
+
self.assertIn("divergent branches", buffer.getvalue())
|
|
124
|
+
|
|
125
|
+
def test_pypi_install_upgrades_without_pull(self) -> None:
|
|
126
|
+
calls = []
|
|
127
|
+
|
|
128
|
+
def fake_run(args, **kwargs):
|
|
129
|
+
calls.append(args)
|
|
130
|
+
return _completed(args, stdout="Nothing to upgrade")
|
|
131
|
+
|
|
132
|
+
with patch("td.__main__._source_checkout", return_value=None), \
|
|
133
|
+
patch("subprocess.run", side_effect=fake_run), \
|
|
134
|
+
redirect_stdout(io.StringIO()):
|
|
135
|
+
_run_update()
|
|
136
|
+
|
|
137
|
+
self.assertEqual(calls, [["uv", "tool", "upgrade", "td-task"]])
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
if __name__ == "__main__":
|
|
141
|
+
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|