igntui 0.2.0__tar.gz → 0.3.1__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.
Files changed (60) hide show
  1. {igntui-0.2.0 → igntui-0.3.1}/PKG-INFO +1 -1
  2. igntui-0.3.1/pyproject.toml +148 -0
  3. igntui-0.2.0/pyproject.toml → igntui-0.3.1/pyproject.toml.orig +1 -1
  4. {igntui-0.2.0 → igntui-0.3.1}/src/igntui/__init__.py +1 -1
  5. {igntui-0.2.0 → igntui-0.3.1}/src/igntui/cli/commands/cache_cmd.py +22 -0
  6. {igntui-0.2.0 → igntui-0.3.1}/src/igntui/cli/commands/completion_cmd.py +7 -2
  7. {igntui-0.2.0 → igntui-0.3.1}/src/igntui/cli/parser.py +5 -0
  8. {igntui-0.2.0 → igntui-0.3.1}/src/igntui/core/cache.py +37 -21
  9. {igntui-0.2.0 → igntui-0.3.1}/LICENSE +0 -0
  10. {igntui-0.2.0 → igntui-0.3.1}/README.md +0 -0
  11. {igntui-0.2.0 → igntui-0.3.1}/src/igntui/__main__.py +0 -0
  12. {igntui-0.2.0 → igntui-0.3.1}/src/igntui/app.py +0 -0
  13. {igntui-0.2.0 → igntui-0.3.1}/src/igntui/cli/__init__.py +0 -0
  14. {igntui-0.2.0 → igntui-0.3.1}/src/igntui/cli/base.py +0 -0
  15. {igntui-0.2.0 → igntui-0.3.1}/src/igntui/cli/commands/__init__.py +0 -0
  16. {igntui-0.2.0 → igntui-0.3.1}/src/igntui/cli/commands/generate_cmd.py +0 -0
  17. {igntui-0.2.0 → igntui-0.3.1}/src/igntui/cli/commands/list_cmd.py +0 -0
  18. {igntui-0.2.0 → igntui-0.3.1}/src/igntui/cli/commands/test_cmd.py +0 -0
  19. {igntui-0.2.0 → igntui-0.3.1}/src/igntui/cli/commands/tui_cmd.py +0 -0
  20. {igntui-0.2.0 → igntui-0.3.1}/src/igntui/cli/setup.py +0 -0
  21. {igntui-0.2.0 → igntui-0.3.1}/src/igntui/core/__init__.py +0 -0
  22. {igntui-0.2.0 → igntui-0.3.1}/src/igntui/core/api/__init__.py +0 -0
  23. {igntui-0.2.0 → igntui-0.3.1}/src/igntui/core/api/client.py +0 -0
  24. {igntui-0.2.0 → igntui-0.3.1}/src/igntui/core/api/errors.py +0 -0
  25. {igntui-0.2.0 → igntui-0.3.1}/src/igntui/core/api/rate_limiter.py +0 -0
  26. {igntui-0.2.0 → igntui-0.3.1}/src/igntui/core/api/request_handler.py +0 -0
  27. {igntui-0.2.0 → igntui-0.3.1}/src/igntui/core/api/response.py +0 -0
  28. {igntui-0.2.0 → igntui-0.3.1}/src/igntui/core/api/types.py +0 -0
  29. {igntui-0.2.0 → igntui-0.3.1}/src/igntui/core/config.py +0 -0
  30. {igntui-0.2.0 → igntui-0.3.1}/src/igntui/core/managed_block.py +0 -0
  31. {igntui-0.2.0 → igntui-0.3.1}/src/igntui/core/project_config.py +0 -0
  32. {igntui-0.2.0 → igntui-0.3.1}/src/igntui/core/repo_config.py +0 -0
  33. {igntui-0.2.0 → igntui-0.3.1}/src/igntui/core/search.py +0 -0
  34. {igntui-0.2.0 → igntui-0.3.1}/src/igntui/core/usage.py +0 -0
  35. {igntui-0.2.0 → igntui-0.3.1}/src/igntui/main.py +0 -0
  36. {igntui-0.2.0 → igntui-0.3.1}/src/igntui/py.typed +0 -0
  37. {igntui-0.2.0 → igntui-0.3.1}/src/igntui/tui/__init__.py +0 -0
  38. {igntui-0.2.0 → igntui-0.3.1}/src/igntui/tui/actions.py +0 -0
  39. {igntui-0.2.0 → igntui-0.3.1}/src/igntui/tui/app.py +0 -0
  40. {igntui-0.2.0 → igntui-0.3.1}/src/igntui/tui/curses_setup.py +0 -0
  41. {igntui-0.2.0 → igntui-0.3.1}/src/igntui/tui/event_handler.py +0 -0
  42. {igntui-0.2.0 → igntui-0.3.1}/src/igntui/tui/lifecycle.py +0 -0
  43. {igntui-0.2.0 → igntui-0.3.1}/src/igntui/tui/renderer.py +0 -0
  44. {igntui-0.2.0 → igntui-0.3.1}/src/igntui/tui/state.py +0 -0
  45. {igntui-0.2.0 → igntui-0.3.1}/src/igntui/tui/updates.py +0 -0
  46. {igntui-0.2.0 → igntui-0.3.1}/src/igntui/ui/__init__.py +0 -0
  47. {igntui-0.2.0 → igntui-0.3.1}/src/igntui/ui/components/__init__.py +0 -0
  48. {igntui-0.2.0 → igntui-0.3.1}/src/igntui/ui/components/base_dialog.py +0 -0
  49. {igntui-0.2.0 → igntui-0.3.1}/src/igntui/ui/components/base_panel.py +0 -0
  50. {igntui-0.2.0 → igntui-0.3.1}/src/igntui/ui/components/confirm_dialog.py +0 -0
  51. {igntui-0.2.0 → igntui-0.3.1}/src/igntui/ui/components/content_panel.py +0 -0
  52. {igntui-0.2.0 → igntui-0.3.1}/src/igntui/ui/components/diff_preview_dialog.py +0 -0
  53. {igntui-0.2.0 → igntui-0.3.1}/src/igntui/ui/components/export_dialog.py +0 -0
  54. {igntui-0.2.0 → igntui-0.3.1}/src/igntui/ui/components/help_dialog.py +0 -0
  55. {igntui-0.2.0 → igntui-0.3.1}/src/igntui/ui/components/save_dialog.py +0 -0
  56. {igntui-0.2.0 → igntui-0.3.1}/src/igntui/ui/components/search_panel.py +0 -0
  57. {igntui-0.2.0 → igntui-0.3.1}/src/igntui/ui/components/selected_panel.py +0 -0
  58. {igntui-0.2.0 → igntui-0.3.1}/src/igntui/ui/components/splash.py +0 -0
  59. {igntui-0.2.0 → igntui-0.3.1}/src/igntui/ui/components/status_bar.py +0 -0
  60. {igntui-0.2.0 → igntui-0.3.1}/src/igntui/ui/components/templates_panel.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: igntui
3
- Version: 0.2.0
3
+ Version: 0.3.1
4
4
  Summary: Interactive Terminal User Interface for GitIgnore template generation
5
5
  Keywords: gitignore,tui,terminal,git,templates,cli
6
6
  Author: Mohammad Abu Mattar
@@ -0,0 +1,148 @@
1
+ [project]
2
+ name = "igntui"
3
+ version = "0.3.1"
4
+ description = "Interactive Terminal User Interface for GitIgnore template generation"
5
+ readme = "README.md"
6
+ license = "GPL-3.0-or-later"
7
+ license-files = ["LICENSE"]
8
+ requires-python = ">=3.11"
9
+ dependencies = [
10
+ "windows-curses>=2.4.1; sys_platform == 'win32'",
11
+ "pyfiglet>=1.0.2",
12
+ "tomli-w>=1.0.0",
13
+ ]
14
+ keywords = [
15
+ "gitignore",
16
+ "tui",
17
+ "terminal",
18
+ "git",
19
+ "templates",
20
+ "cli",
21
+ ]
22
+ classifiers = [
23
+ "Development Status :: 4 - Beta",
24
+ "Environment :: Console :: Curses",
25
+ "Intended Audience :: Developers",
26
+ "Operating System :: OS Independent",
27
+ "Programming Language :: Python :: 3",
28
+ "Programming Language :: Python :: 3.11",
29
+ "Programming Language :: Python :: 3.12",
30
+ "Programming Language :: Python :: 3.13",
31
+ "Topic :: Software Development :: Libraries :: Python Modules",
32
+ "Topic :: Software Development :: Version Control :: Git",
33
+ "Topic :: Terminals",
34
+ "Topic :: Utilities",
35
+ ]
36
+
37
+ [[project.authors]]
38
+ name = "Mohammad Abu Mattar"
39
+ email = "mohammad.khaled@outlook.com"
40
+
41
+ [[project.maintainers]]
42
+ name = "Mohammad Abu Mattar"
43
+ email = "mohammad.khaled@outlook.com"
44
+
45
+ [project.urls]
46
+ Homepage = "https://github.com/MKAbuMattar/igntui"
47
+ Repository = "https://github.com/MKAbuMattar/igntui"
48
+ Documentation = "https://github.com/MKAbuMattar/igntui/blob/main/README.md"
49
+ Issues = "https://github.com/MKAbuMattar/igntui/issues"
50
+
51
+ [project.scripts]
52
+ igntui = "igntui.main:cli_main"
53
+ gitignore-tui = "igntui.main:tui_main"
54
+
55
+ [project.optional-dependencies]
56
+ dev = [
57
+ "pytest>=7.0.0",
58
+ "pytest-cov>=4.0.0",
59
+ "pytest-mock>=3.10.0",
60
+ "ruff>=0.1.0",
61
+ ]
62
+ test = [
63
+ "pytest>=7.0.0",
64
+ "pytest-cov>=4.0.0",
65
+ "pytest-mock>=3.10.0",
66
+ "freezegun>=1.4.0",
67
+ ]
68
+ windows = ["windows-curses>=2.4.1; sys_platform == 'win32'"]
69
+
70
+ [build-system]
71
+ requires = ["uv_build>=0.9.5,<0.13.0"]
72
+ build-backend = "uv_build"
73
+
74
+ [tool.ruff]
75
+ line-length = 100
76
+ target-version = "py311"
77
+
78
+ [tool.ruff.lint]
79
+ select = [
80
+ "E",
81
+ "W",
82
+ "F",
83
+ "I",
84
+ "B",
85
+ "C4",
86
+ "UP",
87
+ ]
88
+ ignore = [
89
+ "E501",
90
+ "B008",
91
+ "C901",
92
+ "E402",
93
+ "W291",
94
+ "W293",
95
+ "C408",
96
+ "C414",
97
+ ]
98
+
99
+ [tool.ruff.lint.per-file-ignores]
100
+ "__init__.py" = ["F401"]
101
+
102
+ [tool.mypy]
103
+ python_version = "3.11"
104
+ warn_return_any = true
105
+ warn_unused_configs = true
106
+ disallow_untyped_defs = false
107
+ ignore_missing_imports = true
108
+
109
+ [[tool.mypy.overrides]]
110
+ module = [
111
+ "curses",
112
+ "curses.*",
113
+ "windows_curses",
114
+ "windows_curses.*",
115
+ ]
116
+ ignore_missing_imports = true
117
+ ignore_errors = true
118
+
119
+ [tool.ty.environment]
120
+ python-version = "3.11"
121
+
122
+ [tool.ty.src]
123
+ include = ["src/igntui"]
124
+
125
+ [tool.pytest.ini_options]
126
+ testpaths = ["tests"]
127
+ python_files = ["test_*.py"]
128
+ addopts = "-v --strict-markers"
129
+ markers = ["integration: tests that hit the network or filesystem extensively"]
130
+
131
+ [tool.coverage.run]
132
+ source = ["src/igntui"]
133
+ omit = [
134
+ "*/tests/*",
135
+ "*/__main__.py",
136
+ "*/tui/curses_setup.py",
137
+ "*/tui/renderer.py",
138
+ "*/ui/*",
139
+ ]
140
+
141
+ [tool.coverage.report]
142
+ exclude_lines = [
143
+ "pragma: no cover",
144
+ "raise NotImplementedError",
145
+ "if __name__ == .__main__.:",
146
+ "if TYPE_CHECKING:",
147
+ ]
148
+ show_missing = true
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "igntui"
3
- version = "0.2.0"
3
+ version = "0.3.1"
4
4
  description = "Interactive Terminal User Interface for GitIgnore template generation"
5
5
  readme = "README.md"
6
6
  license = "GPL-3.0-or-later"
@@ -5,7 +5,7 @@ import platform
5
5
  import sys
6
6
  from collections.abc import Callable
7
7
 
8
- __version__ = "0.2.0"
8
+ __version__ = "0.3.1"
9
9
  __author__ = "Mohammad Abu Mattar"
10
10
  __email__ = "mohammad.khaled@outlook.com"
11
11
  __description__ = """A professional TUI application for generating .gitignore files from templates
@@ -17,6 +17,11 @@ class CacheCommand(CLICommand):
17
17
 
18
18
  clear_parser = subparsers.add_parser("clear", help="Clear cache")
19
19
  clear_parser.add_argument("--force", action="store_true", help="Skip confirmation prompt")
20
+ clear_parser.add_argument(
21
+ "--expired",
22
+ action="store_true",
23
+ help="Remove only entries past their TTL, keeping everything still valid",
24
+ )
20
25
 
21
26
  subparsers.add_parser("stats", help="Show cache statistics")
22
27
  subparsers.add_parser("info", help="Show cache information")
@@ -33,6 +38,8 @@ class CacheCommand(CLICommand):
33
38
  elif args.cache_action == "stats":
34
39
  return self._show_stats(cache)
35
40
  elif args.cache_action == "clear":
41
+ if getattr(args, "expired", False):
42
+ return self._clear_expired(cache)
36
43
  return self._clear_cache(cache, args.force)
37
44
  else:
38
45
  print(f"Unknown cache action: {args.cache_action}")
@@ -85,6 +92,21 @@ class CacheCommand(CLICommand):
85
92
 
86
93
  return 0
87
94
 
95
+ def _clear_expired(self, cache: "CacheManager") -> int:
96
+ """Sweep only what is past its TTL.
97
+
98
+ Nothing prunes expired files on its own any more: reading every entry at
99
+ startup to find them cost every command ~19 ms over a realistic cache.
100
+ Re-requesting a template set overwrites its stale entry in place, so this
101
+ is for sets that were cached once and never asked for again.
102
+ """
103
+ removed = cache.cleanup_expired()
104
+ if removed:
105
+ print(f"Removed {removed} expired {'entry' if removed == 1 else 'entries'}")
106
+ else:
107
+ print("No expired entries")
108
+ return 0
109
+
88
110
  def _clear_cache(self, cache: "CacheManager", force: bool = False) -> int:
89
111
  if not force:
90
112
  response = input("Clear cache? This will remove all cached data. (y/N): ")
@@ -34,7 +34,7 @@ _igntui_complete() {
34
34
  tui) COMPREPLY=( $(compgen -W "--no-splash" -- "$cur") ); return ;;
35
35
  list) COMPREPLY=( $(compgen -W "--filter --count" -- "$cur") ); return ;;
36
36
  generate) COMPREPLY=( $(compgen -W "--output --append --force --dry-run --no-sidecar" -- "$cur") ); return ;;
37
- cache) COMPREPLY=( $(compgen -W "info stats clear --force" -- "$cur") ); return ;;
37
+ cache) COMPREPLY=( $(compgen -W "info stats clear --force --expired" -- "$cur") ); return ;;
38
38
  test) COMPREPLY=( $(compgen -W "--timeout" -- "$cur") ); return ;;
39
39
  completion) COMPREPLY=( $(compgen -W "bash zsh fish" -- "$cur") ); return ;;
40
40
  "") COMPREPLY=( $(compgen -W "$subcommands $global_flags" -- "$cur") ); return ;;
@@ -73,7 +73,10 @@ _igntui() {
73
73
  '--force[overwrite without prompt]' \\
74
74
  '--dry-run[print without writing]' \\
75
75
  '--no-sidecar[skip igntui.cfg.toml]' ;;
76
- cache) _values 'cache action' info stats clear ;;
76
+ cache) _arguments \\
77
+ '1:action:(info stats clear)' \\
78
+ '--force[skip confirmation]' \\
79
+ '--expired[only entries past their TTL]' ;;
77
80
  test) _arguments '--timeout[seconds]:seconds:' ;;
78
81
  completion) _values 'shell' bash zsh fish ;;
79
82
  esac
@@ -110,6 +113,8 @@ complete -c igntui -n "__fish_seen_subcommand_from generate" -l dry-run -d "Prin
110
113
  complete -c igntui -n "__fish_seen_subcommand_from generate" -l no-sidecar -d "Skip sidecar"
111
114
  complete -c igntui -n "__fish_seen_subcommand_from generate" -l force -d "Overwrite without prompt"
112
115
  complete -c igntui -n "__fish_seen_subcommand_from cache" -a "info stats clear"
116
+ complete -c igntui -n "__fish_seen_subcommand_from cache" -l force -d "Skip confirmation"
117
+ complete -c igntui -n "__fish_seen_subcommand_from cache" -l expired -d "Only expired entries"
113
118
  complete -c igntui -n "__fish_seen_subcommand_from completion" -a "bash zsh fish"
114
119
  """
115
120
 
@@ -117,6 +117,11 @@ def create_command_parser(
117
117
 
118
118
  cache_clear_parser = cache_subparsers.add_parser("clear", help="Clear all cache")
119
119
  cache_clear_parser.add_argument("--force", action="store_true", help="Skip confirmation prompt")
120
+ cache_clear_parser.add_argument(
121
+ "--expired",
122
+ action="store_true",
123
+ help="Remove only entries past their TTL, keeping everything still valid",
124
+ )
120
125
  cache_subparsers.add_parser("stats", help="Show cache statistics")
121
126
  cache_subparsers.add_parser("info", help="Show cache information")
122
127
 
@@ -4,7 +4,9 @@
4
4
  import hashlib
5
5
  import json
6
6
  import logging
7
+ import os
7
8
  import re
9
+ import tempfile
8
10
  import time
9
11
  from dataclasses import asdict, dataclass
10
12
  from pathlib import Path
@@ -49,8 +51,19 @@ class CacheManager:
49
51
 
50
52
  self.cache_dir.mkdir(parents=True, exist_ok=True)
51
53
  self._purge_legacy_content_keys()
52
- self._load_persistent_cache()
53
54
 
55
+ # Deliberately not reading the cache into memory here. `get()` already
56
+ # falls back to disk and promotes what it finds, so an eager load bought
57
+ # nothing except latency — and every command pays it, because
58
+ # GitIgnoreAPI constructs a manager on the way to doing anything. Over a
59
+ # realistic cache (300 content blobs, ~3.5 MiB) it measured ~19 ms of
60
+ # open + json.loads before the process could start its actual work.
61
+ #
62
+ # The eager pass also deleted expired files. Losing that is nearly free:
63
+ # a content key is a hash of its template set, so re-requesting the same
64
+ # set overwrites the stale entry in place, and `get()` deletes any
65
+ # expired entry it touches. Only sets never asked for again linger, and
66
+ # `igntui cache clear --expired` sweeps those.
54
67
  logger.debug("Initialized cache manager with directory: %s", self.cache_dir)
55
68
 
56
69
  def _purge_legacy_content_keys(self) -> None:
@@ -196,25 +209,6 @@ class CacheManager:
196
209
  **self._stats,
197
210
  }
198
211
 
199
- def _load_persistent_cache(self) -> None:
200
- try:
201
- loaded_count = 0
202
- for cache_file in self.cache_dir.glob("*.cache"):
203
- key = cache_file.stem
204
- entry = self._load_disk_cache(key)
205
-
206
- if entry and not entry.is_expired():
207
- self._memory_cache[key] = entry
208
- loaded_count += 1
209
- elif entry:
210
- cache_file.unlink()
211
-
212
- if loaded_count > 0:
213
- logger.info("Loaded %d cache entries from disk", loaded_count)
214
-
215
- except Exception as e:
216
- logger.warning("Failed to load persistent cache: %s", e)
217
-
218
212
  def _load_disk_cache(self, key: str) -> CacheEntry | None:
219
213
  cache_file = self.cache_dir / f"{key}.cache"
220
214
 
@@ -236,16 +230,38 @@ class CacheManager:
236
230
  return None
237
231
 
238
232
  def _save_disk_cache(self, key: str, entry: CacheEntry) -> None:
233
+ """Write an entry to disk atomically.
234
+
235
+ Writing in place meant a crash, a full disk, or two processes saving the
236
+ same key could leave truncated JSON behind. That was survivable — a read
237
+ catches the JSONDecodeError and unlinks the file — but the entry was lost
238
+ and the failure was silent. Writing a temp file in the same directory and
239
+ renaming makes the swap atomic on POSIX and Windows: a reader sees either
240
+ the old entry or the new one, never half of either.
241
+ """
239
242
  cache_file = self.cache_dir / f"{key}.cache"
243
+ tmp_path: str | None = None
240
244
 
241
245
  try:
242
- with open(cache_file, "w", encoding="utf-8") as f:
246
+ # Same directory, so os.replace is a rename rather than a cross-device copy.
247
+ fd, tmp_path = tempfile.mkstemp(dir=self.cache_dir, suffix=".tmp")
248
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
243
249
  json.dump(asdict(entry), f, separators=(",", ":"))
250
+ os.replace(tmp_path, cache_file)
251
+ tmp_path = None
244
252
 
245
253
  self._stats["disk_writes"] += 1
246
254
 
247
255
  except (OSError, TypeError) as e:
248
256
  logger.warning("Failed to save cache file %s: %s", cache_file, e)
257
+ finally:
258
+ # A serialisation failure leaves the temp file behind; the previous
259
+ # good entry is still in place, which is the point.
260
+ if tmp_path is not None:
261
+ try:
262
+ os.unlink(tmp_path)
263
+ except OSError:
264
+ pass
249
265
 
250
266
  def _delete_disk_cache(self, key: str) -> bool:
251
267
  cache_file = self.cache_dir / f"{key}.cache"
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
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes