twd-m4sc0 2.0.3__py3-none-any.whl → 3.0.1__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.
twd/__init__.py CHANGED
@@ -0,0 +1,6 @@
1
+ from importlib.metadata import version, PackageNotFoundError
2
+
3
+ try:
4
+ __version__ = version("twd")
5
+ except PackageNotFoundError as e:
6
+ __version__ = "unknown"
twd/cli.py ADDED
@@ -0,0 +1,104 @@
1
+ import click
2
+ import os
3
+ from pathlib import Path
4
+ from . import __version__
5
+ from .config import Config
6
+ from .data import TwdManager
7
+ from .tui import TWDApp
8
+
9
+ @click.group(invoke_without_command=True)
10
+ @click.pass_context
11
+ @click.version_option(version=__version__)
12
+ def cli(ctx):
13
+ """
14
+ TWD - Temp / Tracked Working Directory
15
+ """
16
+ ctx.ensure_object(dict)
17
+
18
+ ctx.obj['config'] = Config.load() # load config
19
+ ctx.obj['manager'] = TwdManager(ctx.obj['config'].data_path)
20
+
21
+ # if no subcommand was provided, launch TUI
22
+ if ctx.invoked_subcommand is None:
23
+ # TODO: launch TUI here
24
+
25
+ path = TWDApp(manager=ctx.obj['manager']).run()
26
+
27
+ # write to fd3
28
+ os.write(3, bytes(str(path), "utf-8"))
29
+
30
+ pass
31
+
32
+ @cli.command()
33
+ @click.argument('path')
34
+ @click.argument('alias', required=False)
35
+ @click.argument('name', required=False)
36
+ @click.pass_context
37
+ def save(ctx, path, alias, name):
38
+ """
39
+ Save a new twd with PATH, [opt] ALIAS and [opt] NAME
40
+ """
41
+ manager: TwdManager = ctx.obj['manager']
42
+
43
+ path_obj = Path(path).expanduser().resolve()
44
+
45
+ # get alias from path
46
+ if alias is None:
47
+ alias = path_obj.name.lower().replace(" ", "_")
48
+
49
+ try:
50
+ entry = manager.add(alias, path_obj, name)
51
+ click.echo(f"Saved '{entry.alias}' -> {entry.path}")
52
+ except ValueError as e:
53
+ click.echo(f"Error: {e}")
54
+ raise click.Abort()
55
+
56
+ @cli.command()
57
+ @click.argument('alias')
58
+ @click.pass_context
59
+ def get(ctx, alias):
60
+ """
61
+ get path for ALIAS
62
+ """
63
+ manager = ctx.obj['manager']
64
+
65
+ entry = manager.get(alias)
66
+
67
+ if entry is None:
68
+ click.echo(f"Alias '{alias}' not found")
69
+ raise click.Abort()
70
+
71
+ # TODO: os.write(3) / write to fd3 for shell integration
72
+ os.write(3, bytes(str(entry.path), "utf-8"))
73
+
74
+ click.echo(f"cd-ing to {entry.path}")
75
+
76
+ @cli.command()
77
+ @click.argument('alias')
78
+ @click.pass_context
79
+ def remove(ctx, alias):
80
+ """
81
+ remove TWD by ALIAS
82
+ """
83
+ manager = ctx.obj['manager']
84
+
85
+ try:
86
+ manager.remove(alias)
87
+ click.echo(f"Removed '{alias}'")
88
+ except KeyError as e:
89
+ click.echo(f"{e}", err=True)
90
+ raise click.Abort()
91
+
92
+ @cli.command('list')
93
+ @click.pass_context
94
+ def list_twds(ctx):
95
+ """List all TWDs"""
96
+ manager = ctx.obj['manager']
97
+
98
+ entries = manager.list_all()
99
+ if not entries:
100
+ click.echo("No TWDs saved yet. Use 'twd save <path> <alias>' to add one.")
101
+ return
102
+
103
+ for entry in entries:
104
+ click.echo(f"{entry.alias:20} {entry.name:30} {entry.path}")
twd/config.py ADDED
@@ -0,0 +1,76 @@
1
+
2
+
3
+ from pathlib import Path
4
+ from pydantic import BaseModel, Field, validator
5
+ import json
6
+ import os
7
+ import sys
8
+
9
+ def get_config_path() -> Path:
10
+ """Cross-platform config location"""
11
+
12
+ if sys.platform == "win32":
13
+ base = Path(os.getenv("APPDATA", Path.home() / "AppData" / "Local"))
14
+ else:
15
+ base = Path(os.getenv("XDG_CONFIG_HOME", Path.home() / ".config"))
16
+
17
+ config_dir = base / "twd"
18
+ config_dir.mkdir(parents=True, exist_ok=True)
19
+
20
+ return config_dir / "config.json"
21
+
22
+ def get_data_path() -> Path:
23
+ """Cross-platform data location"""
24
+
25
+ if sys.platform == "win32":
26
+ base = Path(os.getenv("APPDATA", Path.home() / "AppData" / "Local"))
27
+ else:
28
+ base = Path(os.getenv("XDG_DATA_HOME", Path.home() / ".local" / "share"))
29
+
30
+ data_dir = base / "twd"
31
+ data_dir.mkdir(parents=True, exist_ok=True)
32
+
33
+ return data_dir / "data.csv"
34
+
35
+ class Config(BaseModel):
36
+ """App configuration"""
37
+
38
+ data_path: Path = Field(default_factory=get_data_path)
39
+
40
+ @validator("data_path")
41
+ def validate_path(cls, v):
42
+ """Expand home and ensure path exists"""
43
+
44
+ path = Path(v).expanduser()
45
+ if not path.exists():
46
+ path = get_data_path()
47
+ return path
48
+
49
+ @classmethod
50
+ def load(cls) -> "Config":
51
+ """Load config from file, create with defaults"""
52
+ config_file = get_config_path()
53
+
54
+ if not config_file.exists():
55
+ config = cls()
56
+ config.save()
57
+ return config
58
+
59
+ try:
60
+ with open(config_file, "rb") as f:
61
+ data = json.load(f)
62
+
63
+ return cls(**data)
64
+ except Exception as e:
65
+ print(f"Warning: config file corrupted, using defaults: {e}")
66
+ return cls()
67
+
68
+ def save(self) -> None:
69
+ """save config to file"""
70
+
71
+ config_file = get_config_path()
72
+
73
+ data = self.model_dump(mode="json")
74
+
75
+ with open(config_file, 'w') as f:
76
+ json.dump(data, f, indent=2)
twd/data.py ADDED
@@ -0,0 +1,143 @@
1
+ import csv
2
+
3
+ from pathlib import Path
4
+ from pydantic import BaseModel, Field, validator
5
+ from typing import List, Optional
6
+ from datetime import datetime
7
+
8
+ from .config import Config
9
+ from .utils import search
10
+
11
+ class Entry(BaseModel):
12
+ """Data class for a signle TWD Entry"""
13
+
14
+ alias: str = Field(..., min_length=2, max_length=64)
15
+ path: Path
16
+ name: str = Field(..., min_length=3)
17
+ created_at: datetime = Field(default_factory=datetime.now)
18
+
19
+ @validator("alias")
20
+ def validate_alias(cls, v):
21
+ if not v.replace("_", "").replace("-", "").isalnum():
22
+ raise ValueError("Alias must be alphanumeric with - or _")
23
+
24
+ return v.lower()
25
+
26
+ @validator("path")
27
+ def validate_path(cls, v):
28
+ path = Path(v).expanduser().resolve()
29
+
30
+ if not path.exists():
31
+ raise ValueError(f"Path does not exist: {path}")
32
+ if not path.is_dir():
33
+ raise ValueError(f"Path is not a directory: {path}")
34
+ return path
35
+
36
+ def to_csv(self) -> List[str]:
37
+ """Convert to csv row"""
38
+ return [
39
+ self.alias,
40
+ str(self.path),
41
+ self.name,
42
+ self.created_at.isoformat()
43
+ ]
44
+
45
+ @classmethod
46
+ def from_csv(cls, row: List[str]) -> "Entry":
47
+ """create from csv row"""
48
+ return cls(
49
+ alias=row[0],
50
+ path=Path(row[1]),
51
+ name=row[2],
52
+ created_at=datetime.fromisoformat(row[3])
53
+ )
54
+
55
+ class TwdManager:
56
+ """twd entry manager stored in csv"""
57
+
58
+ CSV_HEADERS = ["alias", "path", "name", "created_at"]
59
+
60
+ def __init__(self, csv_path: Path):
61
+ self.csv_path = csv_path
62
+ self._ensure_csv_exists()
63
+ self.cwd = str(Path.cwd())
64
+
65
+ def _ensure_csv_exists(self) -> None:
66
+ """create csv headers"""
67
+ if not self.csv_path.exists():
68
+ self.csv_path.parent.mkdir(parents=True, exist_ok=True)
69
+
70
+ with open(self.csv_path, 'w', newline='') as f:
71
+ writer = csv.writer(f)
72
+ writer.writerow(self.CSV_HEADERS)
73
+
74
+ def _read_all(self) -> List[Entry]:
75
+ """read all entries"""
76
+ entries = []
77
+
78
+ with open(self.csv_path, 'r', newline='') as f:
79
+ reader = csv.reader(f)
80
+
81
+ next(reader) # skip headers
82
+
83
+ for row in reader:
84
+ try:
85
+ entries.append(Entry.from_csv(row))
86
+ except Exception as e:
87
+ print(f"Warning: skipping invalid row: {e}")
88
+
89
+ return entries
90
+
91
+ def _write_all(self, entries: List[Entry]) -> None:
92
+ with open(self.csv_path, 'w', newline='') as f:
93
+ writer = csv.writer(f)
94
+ writer.writerow(self.CSV_HEADERS)
95
+
96
+ for entry in entries:
97
+ writer.writerow(entry.to_csv())
98
+
99
+ def add(self, alias: str, path: Path, name: Optional[str] = None) -> Entry:
100
+ """Add new entry"""
101
+ entries = self._read_all()
102
+
103
+ if any(e.alias == alias.lower() for e in entries):
104
+ raise ValueError(f"Alias '{alias}' already exists")
105
+
106
+ if name is None:
107
+ name = Path(path).name
108
+
109
+ entry = Entry(alias=alias, path=path, name=name)
110
+ entries.append(entry)
111
+ self._write_all(entries)
112
+
113
+ return entry
114
+
115
+ def get(self, alias: str) -> Optional[Entry]:
116
+ """get entry by alias"""
117
+ entries = self._read_all()
118
+
119
+ for entry in entries:
120
+ if entry.alias == alias.lower():
121
+ return entry
122
+
123
+ return None
124
+
125
+ def remove(self, alias: str) -> None:
126
+ """remove entry by alias"""
127
+ entries = self._read_all()
128
+ original_len = len(entries)
129
+
130
+ entries = [e for e in entries if e.alias != alias.lower()]
131
+
132
+ if len(entries) == original_len:
133
+ raise KeyError(f"Alias '{alias}' not found")
134
+
135
+ self._write_all(entries)
136
+
137
+ def list_all(self) -> List[Entry]:
138
+ entries = self._read_all()
139
+
140
+ return sorted(entries, key=lambda e: e.created_at)
141
+
142
+ def exists(self, alias: str) -> bool:
143
+ return self.get(alias) is not None
twd/tui.py ADDED
@@ -0,0 +1,217 @@
1
+ from enum import Enum
2
+ from textual import on
3
+ from textual.app import App, ComposeResult, Binding
4
+ from textual.containers import HorizontalGroup, VerticalScroll
5
+ from textual.reactive import reactive
6
+ from textual.widgets import Button, Digits, Footer, Header, DataTable, Label, Rule, Input
7
+ from textual.color import Color
8
+
9
+ from twd.config import Config
10
+ from twd.data import TwdManager
11
+ from twd.utils import search, linear_search
12
+
13
+ class Mode(Enum):
14
+ NORMAL = "normal"
15
+ SEARCH = "search"
16
+
17
+ class TWDApp(App):
18
+ """
19
+ TWD TUI Application
20
+ """
21
+
22
+ CSS_PATH = "tui.tcss"
23
+
24
+ BINDINGS = [
25
+ # motion
26
+ Binding("j", "cursor_down", "Down"),
27
+ Binding("k", "cursor_up", "Up"),
28
+
29
+ # modify
30
+ Binding("/", "enter_search_mode", "Search"),
31
+ Binding("escape", "enter_normal_mode", "Normal", show=False),
32
+ # TODO: edit
33
+ # TODO: rename
34
+
35
+ # select
36
+ Binding("enter", "select", "Select"),
37
+
38
+ # exit
39
+ Binding("q", "exit", "Exit"),
40
+ ]
41
+
42
+ mode: Mode = reactive(Mode.NORMAL)
43
+
44
+ def __init__(self, manager: TwdManager, *args, **kwargs):
45
+ self.manager = manager
46
+ super().__init__(*args, **kwargs)
47
+
48
+ def compose(self) -> ComposeResult:
49
+ yield Header(show_clock=True)
50
+ yield Footer()
51
+
52
+ # cwd
53
+ yield Label(f"cwd: {self.manager.cwd}", classes="cwd")
54
+
55
+ yield Input(placeholder="Search...", id="search-input")
56
+
57
+ # twd selection table
58
+ yield DataTable(
59
+ cursor_type='row',
60
+ cell_padding=2,
61
+ # zebra_stripes=True,
62
+ id="data",
63
+ )
64
+
65
+ def on_mount(self) -> None:
66
+ # app config
67
+ self.theme = "flexoki"
68
+ self.title = "TWD"
69
+ self.sub_title = "Tracked Working Directory"
70
+
71
+ search_input = self.query_one("#search-input", Input)
72
+ search_input.display = False
73
+
74
+ self._populate_table()
75
+
76
+ def _populate_table(self, entries=None) -> None:
77
+ """
78
+ fill or refresh data table
79
+ """
80
+ table = self.query_one(DataTable)
81
+ table.clear(columns=True)
82
+
83
+ # add headers
84
+ table.add_columns(*self.manager.CSV_HEADERS)
85
+
86
+ if entries is None:
87
+ entries = self.manager.list_all()
88
+
89
+ # fill data
90
+ for entry in entries:
91
+ table.add_row(entry.alias, str(entry.path), entry.name, entry.created_at)
92
+
93
+ def watch_mode(self, old_mode: Mode, new_mode: Mode) -> None:
94
+ """
95
+ react to mode changes
96
+ """
97
+ search_input = self.query_one("#search-input", Input)
98
+ table = self.query_one(DataTable)
99
+
100
+ if new_mode == Mode.SEARCH:
101
+ # enter search mode
102
+ search_input.display = True
103
+ search_input.value = ""
104
+ search_input.focus()
105
+ self.sub_title = "Tracked Working Directory — SEARCH"
106
+ elif new_mode == Mode.NORMAL:
107
+ # enter normal mode
108
+ search_input.display = False
109
+ search_input.value = ""
110
+ self._populate_table()
111
+ table.focus()
112
+ self.sub_title = "Tracked Working Directory"
113
+
114
+ # actions
115
+ def action_cursor_down(self) -> None:
116
+ """
117
+ move cursor down
118
+ """
119
+ table = self.query_one(DataTable)
120
+
121
+ current_row = table.cursor_coordinate.row
122
+ next_row = (current_row + 1) % table.row_count
123
+
124
+ table.move_cursor(row=next_row)
125
+
126
+ def action_cursor_up(self) -> None:
127
+ """
128
+ move cursor up
129
+ """
130
+ table = self.query_one(DataTable)
131
+
132
+ current_row = table.cursor_coordinate.row
133
+ prev_row = (current_row - 1) % table.row_count
134
+
135
+ table.move_cursor(row=prev_row)
136
+
137
+ def action_enter_search_mode(self) -> None:
138
+ """
139
+ enter search mode
140
+ """
141
+ if self.mode == Mode.SEARCH:
142
+ return
143
+ self.mode = Mode.SEARCH
144
+
145
+ def action_enter_normal_mode(self) -> None:
146
+ """
147
+ enter normal mode
148
+ """
149
+ if self.mode == Mode.NORMAL:
150
+ return
151
+ self.mode = Mode.NORMAL
152
+
153
+ def action_exit(self) -> None:
154
+ self.exit()
155
+
156
+ @on(Input.Changed, "#search-input")
157
+ def on_search_input_changed(self, e: Input.Changed) -> None:
158
+ """
159
+ filter table as user types
160
+ """
161
+ if self.mode != Mode.SEARCH:
162
+ return
163
+
164
+ query = e.value
165
+
166
+ all_entries = self.manager.list_all()
167
+
168
+ # TODO: filter entries and repopulate table
169
+
170
+ search_result = linear_search(query, all_entries)
171
+
172
+ filtered = [entry for entry in all_entries if entry.alias in search_result]
173
+
174
+ self._populate_table(filtered)
175
+
176
+ @on(Input.Submitted, "#search-input")
177
+ def on_search_submitted(self, e: Input.Submitted) -> None:
178
+ """
179
+ when user presses enter in search, return to normal mode
180
+ """
181
+ if self.mode != Mode.SEARCH:
182
+ return
183
+
184
+ self.mode = Mode.NORMAL
185
+
186
+ self.query_one(DataTable).focus()
187
+
188
+
189
+ @on(DataTable.RowSelected)
190
+ def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
191
+ """
192
+ select row and send back to the original program (probably cli)
193
+ """
194
+ table = event.data_table
195
+ row_key = event.row_key
196
+
197
+ # get row
198
+ row_data = table.get_row(row_key)
199
+ alias = row_data[0]
200
+
201
+ # get entry
202
+ entry = self.manager.get(alias)
203
+
204
+ self.notify(f"Selected: {entry.alias} -> {entry.path}")
205
+
206
+ # return selected path to cli
207
+ self.exit(entry.path)
208
+
209
+ if __name__ == "__main__":
210
+ # made sure it works with 'serve'
211
+ config = Config.load()
212
+ manager = TwdManager(config.data_path)
213
+
214
+ app = TWDApp(manager=manager)
215
+ path = app.run()
216
+
217
+ print(path)
twd/utils.py ADDED
@@ -0,0 +1,46 @@
1
+ from rapidfuzz import fuzz
2
+ from typing import List
3
+
4
+ FUZZY_THRESHOLD = 50
5
+
6
+ def normalize(name) -> str:
7
+ return name.lower().replace('-', ' ').replace('_', ' ')
8
+
9
+ def search(query, items, threshold = 50) -> List:
10
+ """
11
+ query: search input
12
+ items: list of (alias, name)
13
+
14
+ returns: list of (alias, name, score)
15
+ """
16
+
17
+ # return all items if query is empty
18
+ if not query:
19
+ return [(alias, name, 100) for alias, name in items]
20
+
21
+ normalized_query = normalize(query)
22
+ results = []
23
+
24
+ # filtering
25
+ for alias, name in items:
26
+ alias_score = fuzz.WRatio(normalized_query, normalize(alias))
27
+ name_score = fuzz.WRatio(normalized_query, normalize(name))
28
+
29
+ # choose higher score
30
+ best_score = max(alias_score, name_score)
31
+
32
+ # filter out low scores
33
+ if best_score >= threshold:
34
+ results.append((alias, name, best_score))
35
+
36
+ results.sort(key=lambda x: x[2], reverse=True)
37
+
38
+ return results
39
+
40
+ def linear_search(query, items) -> List:
41
+ """
42
+ simple substring search
43
+ """
44
+ result = [entry.alias for entry in items if query in entry.alias]
45
+
46
+ return result
@@ -0,0 +1,14 @@
1
+ Metadata-Version: 2.4
2
+ Name: twd-m4sc0
3
+ Version: 3.0.1
4
+ Summary: TWD by m4sc0
5
+ Author: m4sc0
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/m4sc0/twd
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: >=3.13
12
+ Requires-Dist: click>=8
13
+ Requires-Dist: textual>=7.2
14
+ Requires-Dist: pydantic>=2.12
@@ -0,0 +1,11 @@
1
+ twd/__init__.py,sha256=76brvw44gXGskDSsajfwk5rvxoRjk4PT99d4qGUs_64,162
2
+ twd/cli.py,sha256=Shm8ZeHrPKPr-lbo6Mvb_mjWEH8mYBVlpvZ3OcWb2sw,2550
3
+ twd/config.py,sha256=pGAaurguz1Bv6NxjwKbLBbvJ9tuAVJx_l7tcvd0B84I,1996
4
+ twd/data.py,sha256=YPVU2NhZuDOps-pkquRG9y4GW5RHf46eXIm23rgY_Lo,4131
5
+ twd/tui.py,sha256=K48yUMFZbIhGfxzw8-MDaraW7MyakWwVKZJ33GrxzY0,5847
6
+ twd/utils.py,sha256=F42JxIlqUMLsklQ7_zXoIOPmh872E2pHdu1jGLt6-vw,1144
7
+ twd_m4sc0-3.0.1.dist-info/METADATA,sha256=fwhKl3gb_0Eg-UZgzVzFh9ptU-6uxVT2u16I11v7G20,406
8
+ twd_m4sc0-3.0.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
9
+ twd_m4sc0-3.0.1.dist-info/entry_points.txt,sha256=KeIDpaQx0GSqgJFYql14wmT3yj_JHTwq7rRsm-mKckc,36
10
+ twd_m4sc0-3.0.1.dist-info/top_level.txt,sha256=B93Li7fIZ4uWoKq8rKVuGPU_MtzyjxxDb0NF96oGZ0o,4
11
+ twd_m4sc0-3.0.1.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (75.3.0)
2
+ Generator: setuptools (80.9.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ twd = twd.cli:cli
@@ -0,0 +1 @@
1
+ twd
tests/__init__.py DELETED
File without changes
tests/test_twd.py DELETED
@@ -1,20 +0,0 @@
1
- import unittest
2
- import os
3
- from twd import twd
4
-
5
- class TestTWD(unittest.TestCase):
6
- def test_save_directory(self):
7
- twd.save_directory()
8
- self.assertEqual(twd.TWD, os.getcwd())
9
-
10
- def test_save_specified_directory(self):
11
- path = "/tmp"
12
- twd.save_directory(path)
13
- self.assertEqual(twd.TWD, path)
14
-
15
- def test_show_directory(self):
16
- twd.TWD = "/tmp"
17
- self.assertEqual(twd.TWD, "/tmp")
18
-
19
- if __name__ == "__main__":
20
- unittest.main()
twd/__main__.py DELETED
@@ -1,4 +0,0 @@
1
- from .twd import main
2
-
3
- if __name__ == "__main__":
4
- main()