dosctl 0.1.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.
@@ -0,0 +1,23 @@
1
+ from functools import wraps
2
+ import click
3
+ from dosctl.collections.archive_org import TotalDOSCollectionRelease14
4
+ from dosctl.config import DEFAULT_COLLECTION_SOURCE, COLLECTION_CACHE_DIR, ensure_dirs_exist
5
+
6
+ def ensure_cache(f):
7
+ """
8
+ A decorator that ensures the game cache is present and provides the
9
+ collection object to the command.
10
+ """
11
+ @wraps(f)
12
+ def decorated_function(*args, **kwargs):
13
+ ensure_dirs_exist()
14
+ collection = TotalDOSCollectionRelease14(
15
+ source=DEFAULT_COLLECTION_SOURCE,
16
+ cache_dir=COLLECTION_CACHE_DIR
17
+ )
18
+ # This will auto-refresh if the cache is missing
19
+ collection.ensure_cache_is_present()
20
+
21
+ # Pass the collection object to the command
22
+ return f(collection, *args, **kwargs)
23
+ return decorated_function
dosctl/lib/display.py ADDED
@@ -0,0 +1,19 @@
1
+ """Utility functions for displaying game information."""
2
+ import click
3
+
4
+ def sort_games(games, sort_by='name'):
5
+ """Sort games by name or year."""
6
+ if sort_by == 'year':
7
+ return sorted(games, key=lambda g: int(g.get('year', 0) or 0))
8
+ else:
9
+ return sorted(games, key=lambda g: g['name'])
10
+
11
+ def display_games(games, title="Available Games:"):
12
+ """Display a list of games in a consistent format."""
13
+ if not games:
14
+ return
15
+
16
+ click.echo(title)
17
+ for game in games:
18
+ year_str = f"({game.get('year', '----')})"
19
+ click.echo(f" [{game['id']}] {year_str} {game['name']}")
@@ -0,0 +1,20 @@
1
+ """Utilities for handling DOS game executables."""
2
+ from pathlib import Path
3
+ from typing import List
4
+
5
+ def find_executables(game_path: Path) -> List[str]:
6
+ """Find all executable files in a game directory."""
7
+ executables = []
8
+
9
+ # Find .exe files
10
+ executables.extend([e.name for e in game_path.glob('**/*.[eE][xX][eE]')])
11
+ # Find .com files
12
+ executables.extend([e.name for e in game_path.glob('**/*.[cC][oO][mM]')])
13
+ # Find .bat files
14
+ executables.extend([e.name for e in game_path.glob('**/*.[bB][aA][tT]')])
15
+
16
+ return sorted(executables)
17
+
18
+ def executable_exists(game_path: Path, executable_name: str) -> bool:
19
+ """Check if an executable exists in the game directory."""
20
+ return (game_path / executable_name).exists()
dosctl/lib/game.py ADDED
@@ -0,0 +1,32 @@
1
+ from pathlib import Path
2
+ import click
3
+ from dosctl.config import DOWNLOADS_DIR, INSTALLED_DIR
4
+
5
+ def install_game(collection, game_id):
6
+ """
7
+ Ensures a game is installed, downloading and unzipping it if necessary.
8
+ Returns the game object and the installation path.
9
+ """
10
+ # Find the game in the collection
11
+ game = collection._find_game(game_id)
12
+ if not game:
13
+ raise FileNotFoundError(f"Game with ID '{game_id}' not found.")
14
+
15
+ game_install_path = INSTALLED_DIR / game_id
16
+
17
+ # If the game is already installed, we're done.
18
+ if game_install_path.exists():
19
+ click.echo(f"'{game['name']}' is already installed.")
20
+ return game, game_install_path
21
+
22
+ # Download the game if it's not already cached
23
+ download_path = collection.download_game(game_id, str(DOWNLOADS_DIR))
24
+ if not download_path:
25
+ # The download method will print an error, so we just exit.
26
+ return None, None
27
+
28
+ # Unzip the game to its final installation directory
29
+ collection.unzip_game(game_id, DOWNLOADS_DIR, game_install_path)
30
+
31
+ click.echo(f"āœ… Successfully installed '{game['name']}'")
32
+ return game, game_install_path
dosctl/lib/system.py ADDED
@@ -0,0 +1,5 @@
1
+ import shutil
2
+
3
+ def is_dosbox_installed() -> bool:
4
+ """Checks if the 'dosbox' command is available in the system's PATH."""
5
+ return shutil.which('dosbox') is not None
dosctl/lib/ui.py ADDED
@@ -0,0 +1,405 @@
1
+ """User experience and configuration enhancements."""
2
+
3
+ import os
4
+ import json
5
+ import shutil
6
+ from pathlib import Path
7
+ from typing import Dict, List, Any, Tuple
8
+ import click
9
+
10
+ from ..config import get_dosctl_config_dir, get_dosctl_cache_dir
11
+
12
+
13
+ class DOSCtlConfig:
14
+ """Enhanced configuration management with user preferences."""
15
+
16
+ def __init__(self):
17
+ self.config_dir = Path(get_dosctl_config_dir())
18
+ self.config_file = self.config_dir / "config.json"
19
+
20
+ # Check if this is the first run (before loading config)
21
+ self._is_first_run = not self.config_file.exists()
22
+ self._config = self._load_config()
23
+
24
+ # Create config file on first run
25
+ if self._is_first_run:
26
+ self._create_initial_config()
27
+
28
+ def _load_config(self) -> Dict:
29
+ """Load main configuration."""
30
+ default_config = {
31
+ "display": {
32
+ "show_year": True,
33
+ "show_genre": True,
34
+ "progress_bars": True,
35
+ "name_column_width": 80, # Configurable name column width
36
+ "genre_column_width": 20, # Configurable genre column width
37
+ },
38
+ "ui": {
39
+ "confirm_deletion": True,
40
+ "show_tips": True,
41
+ },
42
+ }
43
+
44
+ if self.config_file.exists():
45
+ try:
46
+ with open(self.config_file, "r") as f:
47
+ user_config = json.load(f)
48
+ # Merge with defaults (preserve user settings, add new defaults)
49
+ return self._merge_configs(default_config, user_config)
50
+ except (json.JSONDecodeError, OSError):
51
+ pass
52
+
53
+ return default_config
54
+
55
+ def _merge_configs(self, default: Dict, user: Dict) -> Dict:
56
+ """Recursively merge user config with defaults."""
57
+ result = default.copy()
58
+ for key, value in user.items():
59
+ if (
60
+ key in result
61
+ and isinstance(result[key], dict)
62
+ and isinstance(value, dict)
63
+ ):
64
+ result[key] = self._merge_configs(result[key], value)
65
+ else:
66
+ result[key] = value
67
+ return result
68
+
69
+ def _create_initial_config(self):
70
+ """Create initial config file and notify user."""
71
+ self.save_config()
72
+ print("āœ… Created configuration file at ~/.config/dosctl/config.json")
73
+ print("šŸ’” Tip: Use 'dosctl config show' to view settings or 'dosctl config set <key> <value>' to customize")
74
+
75
+ def save_config(self):
76
+ """Save configuration to file."""
77
+ self.config_dir.mkdir(parents=True, exist_ok=True)
78
+ with open(self.config_file, "w") as f:
79
+ json.dump(self._config, f, indent=2)
80
+
81
+
82
+
83
+ def get(self, path: str, default: Any = None) -> Any:
84
+ """Get configuration value by dot-separated path."""
85
+ keys = path.split(".")
86
+ value = self._config
87
+ for key in keys:
88
+ if isinstance(value, dict) and key in value:
89
+ value = value[key]
90
+ else:
91
+ return default
92
+ return value
93
+
94
+ def _get_valid_config_paths(self) -> set:
95
+ """Get all valid configuration paths from the default config structure."""
96
+ def _extract_paths(config_dict, prefix=""):
97
+ paths = set()
98
+ for key, value in config_dict.items():
99
+ current_path = f"{prefix}.{key}" if prefix else key
100
+ paths.add(current_path)
101
+ if isinstance(value, dict):
102
+ paths.update(_extract_paths(value, current_path))
103
+ return paths
104
+
105
+ default_config = {
106
+ "display": {
107
+ "show_year": True,
108
+ "show_genre": True,
109
+ "progress_bars": True,
110
+ "name_column_width": 80,
111
+ "genre_column_width": 20,
112
+ },
113
+ "ui": {
114
+ "confirm_deletion": True,
115
+ "show_tips": True,
116
+ },
117
+ }
118
+ return _extract_paths(default_config)
119
+
120
+ def set(self, path: str, value: Any):
121
+ """Set configuration value by dot-separated path."""
122
+ # Validate that the path is allowed
123
+ valid_paths = self._get_valid_config_paths()
124
+ if path not in valid_paths:
125
+ # Create a more helpful error message with better formatting
126
+ common_paths = [
127
+ "display.show_year", "display.show_genre", "display.name_column_width",
128
+ "dosbox.default_cycles", "dosbox.fullscreen",
129
+ "ui.confirm_deletion"
130
+ ]
131
+
132
+ # Filter out root-level paths (those without dots) from valid_paths
133
+ full_paths = [p for p in sorted(valid_paths) if '.' in p]
134
+
135
+ # Format the error message with proper line breaks and indentation
136
+ suggestion = "\n\nCommon settings:\n " + "\n ".join(common_paths)
137
+ suggestion += "\n\nAll valid paths:\n " + "\n ".join(full_paths)
138
+ raise ValueError(f"Invalid configuration path '{path}'.{suggestion}")
139
+
140
+ keys = path.split(".")
141
+ config = self._config
142
+ for key in keys[:-1]:
143
+ if key not in config:
144
+ config[key] = {}
145
+ config = config[key]
146
+ config[keys[-1]] = value
147
+ self.save_config()
148
+
149
+
150
+ class UserInterface:
151
+ """Enhanced user interface utilities."""
152
+
153
+ def __init__(self, config: DOSCtlConfig):
154
+ self.config = config
155
+
156
+
157
+ class UserInterface:
158
+ """Enhanced user interface utilities."""
159
+
160
+ def __init__(self, config: DOSCtlConfig):
161
+ self.config = config
162
+
163
+ def print_game_table(self, games: List[Dict], show_status: bool = False):
164
+ """Print games in a formatted table."""
165
+ if not games:
166
+ click.echo("No games found.")
167
+ return
168
+
169
+ # Determine what columns to show
170
+ show_year = self.config.get("display.show_year", True)
171
+ show_genre = self.config.get("display.show_genre", True)
172
+
173
+ # Calculate column widths
174
+ configured_width = self.config.get("display.name_column_width", 80)
175
+ genre_width = self.config.get("display.genre_column_width", 20)
176
+ max_name = max(len(game.get("name", "")) for game in games)
177
+ max_name = min(max_name, configured_width) # Use configured width
178
+
179
+ # Header
180
+ header_parts = [f"{'ID':<8}", f"{'Name':<{max_name}}"]
181
+ if show_year:
182
+ header_parts.append(f"{'Year':<4}")
183
+ if show_status:
184
+ header_parts.append(f"{'Status':<12}")
185
+ if show_genre:
186
+ header_parts.append(f"{'Genre':<{genre_width}}")
187
+
188
+ header = " | ".join(header_parts) + " |" # Add closing pipe
189
+ click.echo(header)
190
+ click.echo("-" * len(header))
191
+
192
+ # Games
193
+ for game in games:
194
+ game_id = game.get("id", "unknown")[:8] # Shorter ID display
195
+ name = game.get("name", "Unknown")[:max_name]
196
+ row_parts = [f"{game_id:<8}", f"{name:<{max_name}}"]
197
+
198
+ if show_year:
199
+ year = game.get("year") or "----" # Handle None gracefully
200
+ row_parts.append(f"{year:<4}")
201
+
202
+ if show_status:
203
+ # This would require integration with installation manager
204
+ status = "Unknown" # Placeholder
205
+ row_parts.append(f"{status:<12}")
206
+
207
+ if show_genre:
208
+ # Parse metadata to get genre
209
+ from ..lib.metadata import parse_game_metadata
210
+
211
+ metadata = parse_game_metadata(game.get("full_path", ""))
212
+ genre = metadata.get("genre") or "Unknown"
213
+ genre = genre[:genre_width] # Use configurable width
214
+ row_parts.append(f"{genre:<{genre_width}}")
215
+
216
+ click.echo(" | ".join(row_parts) + " |") # Add closing pipe
217
+
218
+ # Add closing line of dashes
219
+ click.echo("-" * len(header))
220
+
221
+ def print_game_table_with_scores(self, scored_games: List[Tuple[Dict, float]]):
222
+ """Print games in a formatted table with relevance scores."""
223
+ if not scored_games:
224
+ click.echo("No games found.")
225
+ return
226
+
227
+ games = [game for game, score in scored_games]
228
+
229
+ # Determine what columns to show
230
+ show_year = self.config.get("display.show_year", True)
231
+ show_genre = self.config.get("display.show_genre", True)
232
+
233
+ # Calculate column widths
234
+ configured_width = self.config.get("display.name_column_width", 80)
235
+ genre_width = self.config.get("display.genre_column_width", 20)
236
+ max_name = max(len(game.get("name", "")) for game in games)
237
+ max_name = min(max_name, configured_width) # Use configured width
238
+
239
+ # Header
240
+ header_parts = [f"{'ID':<8}", f"{'Name':<{max_name}}"]
241
+ if show_year:
242
+ header_parts.append(f"{'Year':<4}")
243
+ if show_genre:
244
+ header_parts.append(f"{'Genre':<{genre_width}}")
245
+ header_parts.append(f"{'Score':<5}") # Add score column
246
+
247
+ header = " | ".join(header_parts) + " |" # Add closing pipe
248
+ click.echo(header)
249
+ click.echo("-" * len(header))
250
+
251
+ # Games with scores
252
+ for game, score in scored_games:
253
+ game_id = game.get("id", "unknown")[:8] # Shorter ID display
254
+ name = game.get("name", "Unknown")[:max_name]
255
+ row_parts = [f"{game_id:<8}", f"{name:<{max_name}}"]
256
+
257
+ if show_year:
258
+ year = game.get("year") or "----" # Handle None gracefully
259
+ row_parts.append(f"{year:<4}")
260
+
261
+ if show_genre:
262
+ # Parse metadata to get genre
263
+ from ..lib.metadata import parse_game_metadata
264
+
265
+ metadata = parse_game_metadata(game.get("full_path", ""))
266
+ genre = metadata.get("genre") or "Unknown"
267
+ genre = genre[:genre_width] # Use configurable width
268
+ row_parts.append(f"{genre:<{genre_width}}")
269
+
270
+ # Add score
271
+ row_parts.append(f"{score:<5.1f}")
272
+
273
+ click.echo(" | ".join(row_parts) + " |") # Add closing pipe
274
+
275
+ # Add closing line of dashes
276
+ click.echo("-" * len(header))
277
+
278
+ def print_progress_bar(self, current: int, total: int, description: str = ""):
279
+ """Print a progress bar."""
280
+ if not self.config.get("display.progress_bars", True):
281
+ return
282
+
283
+ width = 40
284
+ progress = current / total if total > 0 else 0
285
+ filled = int(width * progress)
286
+ bar = "ā–ˆ" * filled + "ā–‘" * (width - filled)
287
+ percent = int(progress * 100)
288
+
289
+ status = f"\r{description} [{bar}] {percent}% ({current}/{total})"
290
+ click.echo(status, nl=False)
291
+
292
+ if current >= total:
293
+ click.echo() # New line when complete
294
+
295
+ def confirm_action(self, message: str, default: bool = False) -> bool:
296
+ """Confirm an action with the user."""
297
+ if not self.config.get("ui.confirm_deletion", True):
298
+ return True
299
+ return click.confirm(message, default=default)
300
+
301
+ def print_tip(self, tip: str):
302
+ """Print a helpful tip."""
303
+ if self.config.get("ui.show_tips", True):
304
+ click.echo(f"\nšŸ’” Tip: {tip}")
305
+
306
+
307
+ class CommandAliases:
308
+ """Command aliases and shortcuts."""
309
+
310
+ ALIASES = {
311
+ "ls": "list",
312
+ "find": "search",
313
+ "play": "run",
314
+ "start": "run",
315
+ "launch": "run",
316
+ "info": "inspect",
317
+ "details": "inspect",
318
+ "remove": "delete",
319
+ "rm": "delete",
320
+ "del": "delete",
321
+ "update": "refresh",
322
+ "sync": "refresh",
323
+ }
324
+
325
+ @classmethod
326
+ def resolve_alias(cls, command: str) -> str:
327
+ """Resolve command alias to actual command."""
328
+ return cls.ALIASES.get(command, command)
329
+
330
+ @classmethod
331
+ def list_aliases(cls) -> List[Tuple[str, str]]:
332
+ """List all available aliases."""
333
+ return list(cls.ALIASES.items())
334
+
335
+
336
+ def setup_config_command():
337
+ """Create a config command for managing settings."""
338
+
339
+ @click.group()
340
+ def config():
341
+ """Manage dosctl configuration."""
342
+ pass
343
+
344
+ @config.command()
345
+ @click.argument("key")
346
+ @click.argument("value", required=False)
347
+ def set(key, value):
348
+ """Set a configuration value."""
349
+ config_manager = DOSCtlConfig()
350
+
351
+ if value is None:
352
+ # Show current value
353
+ current = config_manager.get(key)
354
+ if current is not None:
355
+ click.echo(f"{key} = {current}")
356
+ else:
357
+ click.echo(f"Configuration key '{key}' not found.")
358
+ else:
359
+ # Set new value (try to parse as JSON for complex types)
360
+ try:
361
+ parsed_value = json.loads(value)
362
+ except json.JSONDecodeError:
363
+ parsed_value = value
364
+
365
+ try:
366
+ config_manager.set(key, parsed_value)
367
+ click.echo(f"Set {key} = {parsed_value}")
368
+ except ValueError as e:
369
+ click.echo(f"āŒ Error: {e}", err=True)
370
+ return
371
+
372
+ @config.command()
373
+ def show():
374
+ """Show current configuration."""
375
+ config_manager = DOSCtlConfig()
376
+ click.echo(json.dumps(config_manager._config, indent=2))
377
+
378
+
379
+
380
+ return config
381
+
382
+
383
+ def get_system_info() -> Dict:
384
+ """Get system information for diagnostics."""
385
+ info = {
386
+ "os": os.name,
387
+ "platform": os.uname() if hasattr(os, "uname") else "unknown",
388
+ "python_version": f"{os.sys.version_info.major}.{os.sys.version_info.minor}.{os.sys.version_info.micro}",
389
+ "dosctl_cache_dir": get_dosctl_cache_dir(),
390
+ "dosctl_config_dir": get_dosctl_config_dir(),
391
+ }
392
+
393
+ # Check for DOSBox
394
+ dosbox_path = shutil.which("dosbox")
395
+ info["dosbox_available"] = dosbox_path is not None
396
+ info["dosbox_path"] = dosbox_path
397
+
398
+ # Check disk space
399
+ try:
400
+ cache_stat = shutil.disk_usage(get_dosctl_cache_dir())
401
+ info["cache_disk_free_gb"] = round(cache_stat.free / (1024**3), 2)
402
+ except OSError:
403
+ info["cache_disk_free_gb"] = "unknown"
404
+
405
+ return info
dosctl/main.py ADDED
@@ -0,0 +1,22 @@
1
+ import click
2
+ from .commands.list import list_games
3
+ from .commands.search import search
4
+ from .commands.run import run
5
+ from .commands.inspect import inspect
6
+ from .commands.delete import delete
7
+ from .commands.refresh import refresh
8
+
9
+ @click.group()
10
+ def cli():
11
+ """A command-line tool to manage and play DOS games."""
12
+ pass
13
+
14
+ cli.add_command(list_games)
15
+ cli.add_command(search)
16
+ cli.add_command(run)
17
+ cli.add_command(inspect)
18
+ cli.add_command(delete)
19
+ cli.add_command(refresh)
20
+
21
+ if __name__ == "__main__":
22
+ cli()