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.
@@ -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
- run: doppler run -- sh -c 'UV_PUBLISH_TOKEN="$PYPI_API" uv publish'
32
- env:
33
- DOPPLER_TOKEN: ${{ secrets.DOPPLER_TOKEN }}
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.0
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,6 +1,6 @@
1
1
  [project]
2
2
  name = "td-task"
3
- version = "0.2.0"
3
+ version = "0.2.2"
4
4
  description = "Fast TUI and CLI manager for tasks and Markdown notes"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.14"
@@ -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 PyPI."""
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
- ["uv", "tool", "upgrade", "td-task"],
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 range(scroll, end):
600
- prefix = f"{'▸' if line_index == row else ' '} {line_index + 1:>{number_width}} │ "
601
- line = lines[line_index]
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
- output.append(_markdown_preview_line(line))
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 - (end - scroll)):
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
- if row < scroll:
647
- scroll = row
648
- elif row >= scroll + visible_rows:
649
- scroll = row - visible_rows + 1
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()
@@ -142,7 +142,7 @@ wheels = [
142
142
 
143
143
  [[package]]
144
144
  name = "td-task"
145
- version = "0.2.0"
145
+ version = "0.2.1"
146
146
  source = { editable = "." }
147
147
  dependencies = [
148
148
  { name = "cryptography" },
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