igntui 0.0.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 (52) hide show
  1. igntui/__init__.py +57 -0
  2. igntui/__main__.py +10 -0
  3. igntui/app.py +37 -0
  4. igntui/cli/__init__.py +31 -0
  5. igntui/cli/base.py +104 -0
  6. igntui/cli/commands/__init__.py +17 -0
  7. igntui/cli/commands/cache_cmd.py +95 -0
  8. igntui/cli/commands/generate_cmd.py +85 -0
  9. igntui/cli/commands/list_cmd.py +66 -0
  10. igntui/cli/commands/test_cmd.py +64 -0
  11. igntui/cli/commands/tui_cmd.py +34 -0
  12. igntui/cli/parser.py +162 -0
  13. igntui/cli/setup.py +71 -0
  14. igntui/core/__init__.py +8 -0
  15. igntui/core/api/__init__.py +16 -0
  16. igntui/core/api/client.py +222 -0
  17. igntui/core/api/errors.py +29 -0
  18. igntui/core/api/rate_limiter.py +30 -0
  19. igntui/core/api/request_handler.py +124 -0
  20. igntui/core/api/response.py +16 -0
  21. igntui/core/cache.py +284 -0
  22. igntui/core/config.py +174 -0
  23. igntui/core/search.py +354 -0
  24. igntui/main.py +74 -0
  25. igntui/tui/__init__.py +22 -0
  26. igntui/tui/actions.py +254 -0
  27. igntui/tui/app.py +246 -0
  28. igntui/tui/curses_setup.py +88 -0
  29. igntui/tui/event_handler.py +310 -0
  30. igntui/tui/lifecycle.py +102 -0
  31. igntui/tui/renderer.py +108 -0
  32. igntui/tui/state.py +77 -0
  33. igntui/ui/__init__.py +33 -0
  34. igntui/ui/components/__init__.py +31 -0
  35. igntui/ui/components/base_dialog.py +75 -0
  36. igntui/ui/components/base_panel.py +85 -0
  37. igntui/ui/components/confirm_dialog.py +100 -0
  38. igntui/ui/components/content_panel.py +77 -0
  39. igntui/ui/components/export_dialog.py +139 -0
  40. igntui/ui/components/help_dialog.py +161 -0
  41. igntui/ui/components/save_dialog.py +109 -0
  42. igntui/ui/components/search_panel.py +52 -0
  43. igntui/ui/components/selected_panel.py +107 -0
  44. igntui/ui/components/splash.py +236 -0
  45. igntui/ui/components/status_bar.py +78 -0
  46. igntui/ui/components/templates_panel.py +153 -0
  47. igntui/utils/__init__.py +19 -0
  48. igntui/utils/logging.py +288 -0
  49. igntui-0.0.0.dist-info/METADATA +425 -0
  50. igntui-0.0.0.dist-info/RECORD +52 -0
  51. igntui-0.0.0.dist-info/WHEEL +4 -0
  52. igntui-0.0.0.dist-info/entry_points.txt +4 -0
igntui/core/cache.py ADDED
@@ -0,0 +1,284 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+
4
+
5
+ import json
6
+ import logging
7
+ import os
8
+ import time
9
+ from dataclasses import asdict, dataclass
10
+ from pathlib import Path
11
+ from threading import RLock
12
+ from typing import Any, Dict, List, Optional, Set
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ @dataclass
18
+ class CacheEntry:
19
+ data: Any
20
+ timestamp: float
21
+ ttl: int
22
+ access_count: int = 0
23
+ last_access: Optional[float] = None
24
+
25
+ def is_expired(self) -> bool:
26
+ return time.time() > (self.timestamp + self.ttl)
27
+
28
+ def touch(self) -> None:
29
+ self.access_count += 1
30
+ self.last_access = time.time()
31
+
32
+
33
+ class CacheManager:
34
+ def __init__(self, cache_dir: str, default_ttl: int = 3600):
35
+ self.cache_dir = Path(cache_dir)
36
+ self.default_ttl = default_ttl
37
+ self._memory_cache: Dict[str, CacheEntry] = {}
38
+ self._lock = RLock()
39
+
40
+ self._stats = {
41
+ "hits": 0,
42
+ "misses": 0,
43
+ "sets": 0,
44
+ "deletes": 0,
45
+ "evictions": 0,
46
+ "disk_reads": 0,
47
+ "disk_writes": 0,
48
+ }
49
+
50
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
51
+ self._load_persistent_cache()
52
+
53
+ logger.debug(f"Initialized cache manager with directory: {self.cache_dir}")
54
+
55
+ def get(self, key: str) -> Optional[Any]:
56
+ with self._lock:
57
+ if key in self._memory_cache:
58
+ entry = self._memory_cache[key]
59
+
60
+ if entry.is_expired():
61
+ del self._memory_cache[key]
62
+ self._delete_disk_cache(key)
63
+ self._stats["evictions"] += 1
64
+ self._stats["misses"] += 1
65
+ return None
66
+
67
+ entry.touch()
68
+ self._stats["hits"] += 1
69
+ logger.debug(f"Cache hit for key: {key}")
70
+ return entry.data
71
+
72
+ disk_entry = self._load_disk_cache(key)
73
+ if disk_entry and not disk_entry.is_expired():
74
+ self._memory_cache[key] = disk_entry
75
+ disk_entry.touch()
76
+ self._stats["hits"] += 1
77
+ logger.debug(f"Disk cache hit for key: {key}")
78
+ return disk_entry.data
79
+ elif disk_entry:
80
+ self._delete_disk_cache(key)
81
+ self._stats["evictions"] += 1
82
+
83
+ self._stats["misses"] += 1
84
+ return None
85
+
86
+ def set(self, key: str, value: Any, ttl: Optional[int] = None) -> None:
87
+ ttl = ttl or self.default_ttl
88
+
89
+ with self._lock:
90
+ entry = CacheEntry(data=value, timestamp=time.time(), ttl=ttl)
91
+
92
+ self._memory_cache[key] = entry
93
+ self._save_disk_cache(key, entry)
94
+ self._stats["sets"] += 1
95
+
96
+ logger.debug(f"Cached value for key: {key} (TTL: {ttl}s)")
97
+
98
+ def delete(self, key: str) -> bool:
99
+ with self._lock:
100
+ deleted = False
101
+
102
+ if key in self._memory_cache:
103
+ del self._memory_cache[key]
104
+ deleted = True
105
+
106
+ if self._delete_disk_cache(key):
107
+ deleted = True
108
+
109
+ if deleted:
110
+ self._stats["deletes"] += 1
111
+ logger.debug(f"Deleted cache entry: {key}")
112
+
113
+ return deleted
114
+
115
+ def clear(self) -> int:
116
+ with self._lock:
117
+ memory_count = len(self._memory_cache)
118
+ self._memory_cache.clear()
119
+ disk_count = 0
120
+ for cache_file in self.cache_dir.glob("*.cache"):
121
+ try:
122
+ cache_file.unlink()
123
+ disk_count += 1
124
+ except OSError:
125
+ pass
126
+
127
+ total_cleared = memory_count + disk_count
128
+ logger.info(f"Cleared {total_cleared} cache entries")
129
+ return total_cleared
130
+
131
+ def cleanup_expired(self) -> int:
132
+ with self._lock:
133
+ expired_keys = []
134
+
135
+ for key, entry in self._memory_cache.items():
136
+ if entry.is_expired():
137
+ expired_keys.append(key)
138
+
139
+ for key in expired_keys:
140
+ del self._memory_cache[key]
141
+ self._delete_disk_cache(key)
142
+
143
+ disk_cleaned = 0
144
+ for cache_file in self.cache_dir.glob("*.cache"):
145
+ key = cache_file.stem
146
+ if key not in self._memory_cache:
147
+ entry = self._load_disk_cache(key)
148
+ if entry and entry.is_expired():
149
+ cache_file.unlink()
150
+ disk_cleaned += 1
151
+
152
+ total_cleaned = len(expired_keys) + disk_cleaned
153
+ self._stats["evictions"] += total_cleaned
154
+
155
+ if total_cleaned > 0:
156
+ logger.info(f"Cleaned up {total_cleaned} expired cache entries")
157
+
158
+ return total_cleaned
159
+
160
+ def get_stats(self) -> Dict[str, Any]:
161
+ with self._lock:
162
+ total_requests = self._stats["hits"] + self._stats["misses"]
163
+ hit_rate = self._stats["hits"] / max(1, total_requests)
164
+
165
+ memory_entries = len(self._memory_cache)
166
+ disk_entries = len(list(self.cache_dir.glob("*.cache")))
167
+
168
+ return {
169
+ "hit_rate": hit_rate,
170
+ "total_requests": total_requests,
171
+ "memory_entries": memory_entries,
172
+ "disk_entries": disk_entries,
173
+ "cache_dir": str(self.cache_dir),
174
+ "default_ttl": self.default_ttl,
175
+ **self._stats,
176
+ }
177
+
178
+ def _load_persistent_cache(self) -> None:
179
+ try:
180
+ loaded_count = 0
181
+ for cache_file in self.cache_dir.glob("*.cache"):
182
+ key = cache_file.stem
183
+ entry = self._load_disk_cache(key)
184
+
185
+ if entry and not entry.is_expired():
186
+ self._memory_cache[key] = entry
187
+ loaded_count += 1
188
+ elif entry:
189
+ cache_file.unlink()
190
+
191
+ if loaded_count > 0:
192
+ logger.info(f"Loaded {loaded_count} cache entries from disk")
193
+
194
+ except Exception as e:
195
+ logger.warning(f"Failed to load persistent cache: {e}")
196
+
197
+ def _load_disk_cache(self, key: str) -> Optional[CacheEntry]:
198
+ cache_file = self.cache_dir / f"{key}.cache"
199
+
200
+ try:
201
+ if cache_file.exists():
202
+ with open(cache_file, "r", encoding="utf-8") as f:
203
+ data = json.load(f)
204
+
205
+ self._stats["disk_reads"] += 1
206
+ return CacheEntry(**data)
207
+
208
+ except (json.JSONDecodeError, TypeError, KeyError, OSError) as e:
209
+ logger.warning(f"Failed to load cache file {cache_file}: {e}")
210
+ try:
211
+ cache_file.unlink()
212
+ except OSError:
213
+ pass
214
+
215
+ return None
216
+
217
+ def _save_disk_cache(self, key: str, entry: CacheEntry) -> None:
218
+ cache_file = self.cache_dir / f"{key}.cache"
219
+
220
+ try:
221
+ with open(cache_file, "w", encoding="utf-8") as f:
222
+ json.dump(asdict(entry), f, separators=(",", ":"))
223
+
224
+ self._stats["disk_writes"] += 1
225
+
226
+ except (OSError, TypeError) as e:
227
+ logger.warning(f"Failed to save cache file {cache_file}: {e}")
228
+
229
+ def _delete_disk_cache(self, key: str) -> bool:
230
+ cache_file = self.cache_dir / f"{key}.cache"
231
+
232
+ try:
233
+ if cache_file.exists():
234
+ cache_file.unlink()
235
+ return True
236
+ except OSError:
237
+ pass
238
+
239
+ return False
240
+
241
+
242
+ class TemplateCache:
243
+ def __init__(self, cache_manager: CacheManager):
244
+ self.cache_manager = cache_manager
245
+ self._template_list_key = "gitignore_templates_list"
246
+ self._template_content_prefix = "gitignore_content_"
247
+
248
+ def get_template_list(self) -> Optional[List[str]]:
249
+ return self.cache_manager.get(self._template_list_key)
250
+
251
+ def set_template_list(self, templates: List[str]) -> None:
252
+ self.cache_manager.set(self._template_list_key, templates)
253
+
254
+ def get_template_content(self, technologies: List[str]) -> Optional[str]:
255
+ key = self._make_content_key(technologies)
256
+ return self.cache_manager.get(key)
257
+
258
+ def set_template_content(self, technologies: List[str], content: str) -> None:
259
+ key = self._make_content_key(technologies)
260
+ self.cache_manager.set(key, content)
261
+
262
+ def invalidate_template_content(self, template_name: str) -> int:
263
+ invalidated = 0
264
+
265
+ for cache_file in self.cache_manager.cache_dir.glob(
266
+ f"{self._template_content_prefix}*.cache"
267
+ ):
268
+ key = cache_file.stem
269
+
270
+ if template_name.lower() in key.lower():
271
+ if self.cache_manager.delete(key):
272
+ invalidated += 1
273
+
274
+ if invalidated > 0:
275
+ logger.info(
276
+ f"Invalidated {invalidated} cache entries for template: {template_name}"
277
+ )
278
+
279
+ return invalidated
280
+
281
+ def _make_content_key(self, technologies: List[str]) -> str:
282
+ sorted_techs = sorted([tech.lower().strip() for tech in technologies])
283
+ tech_string = ",".join(sorted_techs)
284
+ return f"{self._template_content_prefix}{hash(tech_string) % 1000000:06d}"
igntui/core/config.py ADDED
@@ -0,0 +1,174 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+
4
+
5
+ import json
6
+ import logging
7
+ import os
8
+ from pathlib import Path
9
+ from typing import Any, Dict, Optional
10
+
11
+ from .. import __version__
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class Config:
17
+ DEFAULT_CONFIG = {
18
+ "api": {
19
+ "base_url": "https://www.toptal.com/developers/gitignore/api",
20
+ "timeout": 10,
21
+ "user_agent": f"igntui/{__version__}",
22
+ "cache_ttl": 3600,
23
+ "retry_attempts": 3,
24
+ },
25
+ "ui": {
26
+ "theme": "default",
27
+ "mouse_support": True,
28
+ "auto_save": False,
29
+ "panel_layout": "four_panel",
30
+ "show_help": True,
31
+ "animation_speed": 150,
32
+ },
33
+ "behavior": {
34
+ "max_recent_templates": 10,
35
+ "auto_refresh_interval": 3600,
36
+ "fuzzy_search_threshold": 0.6,
37
+ "save_usage_stats": True,
38
+ "auto_backup": True,
39
+ "max_cache_entries": 1000,
40
+ },
41
+ "logging": {
42
+ "level": "INFO",
43
+ "file_enabled": True,
44
+ "console_enabled": False,
45
+ "max_file_size": 10485760,
46
+ "backup_count": 5,
47
+ },
48
+ }
49
+
50
+ def __init__(self, config_path: Optional[Path] = None):
51
+ self.config_path = config_path or Path.home() / ".igntui.json"
52
+ self._config = self.DEFAULT_CONFIG.copy()
53
+ self._load_config()
54
+
55
+ def _load_config(self) -> None:
56
+ if self.config_path.exists():
57
+ try:
58
+ with open(self.config_path, "r", encoding="utf-8") as f:
59
+ file_config = json.load(f)
60
+ self._merge_config(file_config)
61
+ logger.info(f"Loaded configuration from {self.config_path}")
62
+ except (json.JSONDecodeError, OSError) as e:
63
+ logger.warning(f"Failed to load config file: {e}")
64
+
65
+ self._load_env_overrides()
66
+
67
+ def _merge_config(self, new_config: Dict[str, Any]) -> None:
68
+ def merge_dict(base: Dict, override: Dict) -> Dict:
69
+ result = base.copy()
70
+ for key, value in override.items():
71
+ if (
72
+ key in result
73
+ and isinstance(result[key], dict)
74
+ and isinstance(value, dict)
75
+ ):
76
+ result[key] = merge_dict(result[key], value)
77
+ else:
78
+ result[key] = value
79
+ return result
80
+
81
+ self._config = merge_dict(self._config, new_config)
82
+
83
+ def _load_env_overrides(self) -> None:
84
+ env_mappings = {
85
+ "IGNTUI_API_URL": ["api", "base_url"],
86
+ "IGNTUI_API_TIMEOUT": ["api", "timeout"],
87
+ "IGNTUI_CACHE_TTL": ["api", "cache_ttl"],
88
+ "IGNTUI_THEME": ["ui", "theme"],
89
+ "IGNTUI_MOUSE": ["ui", "mouse_support"],
90
+ "IGNTUI_LOG_LEVEL": ["logging", "level"],
91
+ "IGNTUI_MAX_RECENT": ["behavior", "max_recent_templates"],
92
+ }
93
+
94
+ for env_var, config_path in env_mappings.items():
95
+ value = os.getenv(env_var)
96
+ if value is not None:
97
+ try:
98
+ if value.lower() in ("true", "false"):
99
+ value = value.lower() == "true"
100
+ elif value.isdigit():
101
+ value = int(value)
102
+ elif value.replace(".", "").isdigit():
103
+ value = float(value)
104
+
105
+ config_section = self._config
106
+ for key in config_path[:-1]:
107
+ config_section = config_section[key]
108
+ config_section[config_path[-1]] = value
109
+
110
+ logger.debug(
111
+ f"Set {'.'.join(config_path)} = {value} from {env_var}"
112
+ )
113
+ except (ValueError, KeyError) as e:
114
+ logger.warning(f"Failed to set config from {env_var}: {e}")
115
+
116
+ def get(self, *keys: str, default: Any = None) -> Any:
117
+ try:
118
+ value = self._config
119
+ for key in keys:
120
+ value = value[key]
121
+ return value
122
+ except (KeyError, TypeError):
123
+ return default
124
+
125
+ def set(self, *keys: str, value: Any) -> None:
126
+ config_section = self._config
127
+ for key in keys[:-1]:
128
+ if key not in config_section:
129
+ config_section[key] = {}
130
+ config_section = config_section[key]
131
+ config_section[keys[-1]] = value
132
+
133
+ def save(self) -> None:
134
+ try:
135
+ self.config_path.parent.mkdir(parents=True, exist_ok=True)
136
+ with open(self.config_path, "w", encoding="utf-8") as f:
137
+ json.dump(self._config, f, indent=2, sort_keys=True)
138
+ logger.info(f"Saved configuration to {self.config_path}")
139
+ except OSError as e:
140
+ logger.error(f"Failed to save configuration: {e}")
141
+
142
+ def reset_to_defaults(self) -> None:
143
+ self._config = self.DEFAULT_CONFIG.copy()
144
+ logger.info("Reset configuration to defaults")
145
+
146
+ def get_cache_dir(self) -> Path:
147
+ cache_dir = Path.home() / ".cache" / "igntui"
148
+ cache_dir.mkdir(parents=True, exist_ok=True)
149
+ return cache_dir
150
+
151
+ def get_log_file(self) -> Path:
152
+ return Path.home() / ".igntui.log"
153
+
154
+ def get_usage_file(self) -> Path:
155
+ return Path.home() / ".igntui_usage.json"
156
+
157
+ @property
158
+ def api_config(self) -> Dict[str, Any]:
159
+ return self._config["api"]
160
+
161
+ @property
162
+ def ui_config(self) -> Dict[str, Any]:
163
+ return self._config["ui"]
164
+
165
+ @property
166
+ def behavior_config(self) -> Dict[str, Any]:
167
+ return self._config["behavior"]
168
+
169
+ @property
170
+ def logging_config(self) -> Dict[str, Any]:
171
+ return self._config["logging"]
172
+
173
+
174
+ config = Config()