pysfi 0.1.12__py3-none-any.whl → 0.1.14__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 (42) hide show
  1. {pysfi-0.1.12.dist-info → pysfi-0.1.14.dist-info}/METADATA +1 -1
  2. pysfi-0.1.14.dist-info/RECORD +68 -0
  3. {pysfi-0.1.12.dist-info → pysfi-0.1.14.dist-info}/entry_points.txt +3 -0
  4. sfi/__init__.py +19 -2
  5. sfi/alarmclock/__init__.py +3 -0
  6. sfi/alarmclock/alarmclock.py +23 -40
  7. sfi/bumpversion/__init__.py +3 -1
  8. sfi/bumpversion/bumpversion.py +64 -15
  9. sfi/cleanbuild/__init__.py +3 -0
  10. sfi/cleanbuild/cleanbuild.py +5 -1
  11. sfi/cli.py +25 -4
  12. sfi/condasetup/__init__.py +1 -0
  13. sfi/condasetup/condasetup.py +91 -76
  14. sfi/docdiff/__init__.py +1 -0
  15. sfi/docdiff/docdiff.py +3 -2
  16. sfi/docscan/__init__.py +1 -1
  17. sfi/docscan/docscan.py +78 -23
  18. sfi/docscan/docscan_gui.py +152 -48
  19. sfi/filedate/filedate.py +12 -5
  20. sfi/img2pdf/img2pdf.py +453 -0
  21. sfi/llmclient/llmclient.py +31 -8
  22. sfi/llmquantize/llmquantize.py +76 -37
  23. sfi/llmserver/__init__.py +1 -0
  24. sfi/llmserver/llmserver.py +63 -13
  25. sfi/makepython/makepython.py +1145 -201
  26. sfi/pdfsplit/pdfsplit.py +45 -12
  27. sfi/pyarchive/__init__.py +1 -0
  28. sfi/pyarchive/pyarchive.py +908 -278
  29. sfi/pyembedinstall/pyembedinstall.py +88 -89
  30. sfi/pylibpack/pylibpack.py +561 -463
  31. sfi/pyloadergen/pyloadergen.py +372 -218
  32. sfi/pypack/pypack.py +510 -959
  33. sfi/pyprojectparse/pyprojectparse.py +337 -40
  34. sfi/pysourcepack/__init__.py +1 -0
  35. sfi/pysourcepack/pysourcepack.py +210 -131
  36. sfi/quizbase/quizbase_gui.py +2 -2
  37. sfi/taskkill/taskkill.py +168 -59
  38. sfi/which/which.py +11 -3
  39. pysfi-0.1.12.dist-info/RECORD +0 -62
  40. sfi/workflowengine/workflowengine.py +0 -444
  41. {pysfi-0.1.12.dist-info → pysfi-0.1.14.dist-info}/WHEEL +0 -0
  42. /sfi/{workflowengine → img2pdf}/__init__.py +0 -0
@@ -1,14 +1,46 @@
1
+ """MakePython: Automated Python project building and management tool.
2
+
3
+ This module provides a command-line interface for common Python development tasks
4
+ including building, testing, publishing, and version management.
5
+
6
+ Features:
7
+ - Automatic build tool detection (uv, poetry, hatch)
8
+ - Project configuration management
9
+ - Command execution with proper error handling
10
+ - PyPI token management
11
+ - Clean build artifacts
12
+ - Test execution with various configurations
13
+
14
+ Example:
15
+ >>> # Build project
16
+ >>> makepython build
17
+ >>>
18
+ >>> # Run tests
19
+ >>> makepython test
20
+ >>>
21
+ >>> # Publish to PyPI
22
+ >>> makepython publish
23
+ """
24
+
1
25
  from __future__ import annotations
2
26
 
27
+ __version__: Final[str] = "0.2.0"
28
+ __author__: Final[str] = "pysfi Developers"
29
+ __email__: Final[str] = "developers@pysfi.org"
3
30
  import argparse
31
+ import atexit
32
+ import json
4
33
  import logging
5
34
  import os
6
35
  import shutil
7
36
  import subprocess
8
37
  import sys
38
+ import time
9
39
  from dataclasses import dataclass
40
+ from enum import Enum
41
+ from functools import cached_property
10
42
  from pathlib import Path
11
- from typing import Any, Callable
43
+ from typing import Any, Callable, Final, Protocol
12
44
 
13
45
  if sys.version_info >= (3, 11):
14
46
  import tomllib
@@ -16,271 +48,1182 @@ else:
16
48
  import tomli as tomllib # type: ignore
17
49
 
18
50
 
19
- is_windows = sys.platform == "win32"
20
- is_linux = sys.platform == "linux"
21
- is_macos = sys.platform == "darwin"
51
+ # Platform detection
52
+ IS_WINDOWS: Final[bool] = sys.platform == "win32"
53
+
54
+ # File and directory constants
55
+ CONFIG_DIR_NAME: Final[str] = ".pysfi"
56
+ CONFIG_FILE_NAME: Final[str] = "makepython.json"
57
+ PYPROJECT_FILE_NAME: Final[str] = "pyproject.toml"
58
+
59
+ # Default constants
60
+ DEFAULT_TIMEOUT_SECONDS: Final[int] = 300
61
+ DEFAULT_MAX_RETRIES: Final[int] = 3
62
+ DEFAULT_ENCODING: Final[str] = "utf-8"
63
+ DEFAULT_SHELL_CONFIG: Final[str] = ".bashrc"
64
+ DEFAULT_CACHE_SIZE: Final[int] = 100
65
+ DEFAULT_CACHE_EXPIRY: Final[int] = 300 # 5 minutes
66
+
67
+ # Clean command template
68
+ CLEAN_COMMANDS: Final[list[str]] = ["rm", "-rf"]
69
+
70
+ # Logging setup
71
+ LOG_FORMAT: Final[str] = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
72
+ LOG_DATE_FORMAT: Final[str] = "%Y-%m-%d %H:%M:%S"
22
73
 
23
- logging.basicConfig(level=logging.INFO, format="%(message)s")
74
+ logging.basicConfig(level=logging.INFO, format=LOG_FORMAT, datefmt=LOG_DATE_FORMAT)
24
75
  logger = logging.getLogger(__name__)
25
- cwd = Path.cwd()
76
+ CURRENT_WORKING_DIR: Final[Path] = Path.cwd()
26
77
 
27
- _BUILD_COMMANDS = ["uv", "poetry", "hatch"]
78
+ CONFIG_FILE: Final[Path] = Path.home() / CONFIG_DIR_NAME / CONFIG_FILE_NAME
28
79
 
29
80
 
30
- def parse_pyproject_toml(directory: Path) -> dict:
31
- """Parse pyproject.toml file in directory and return project data."""
32
- project_toml = directory / "pyproject.toml"
33
- if not project_toml.is_file():
34
- logger.error(f"No pyproject.toml found in {directory}")
35
- return {}
81
+ class MakePythonError(Exception):
82
+ """Base exception class for MakePython tool errors."""
83
+
84
+ def __init__(self, message: str, error_code: int = 1):
85
+ super().__init__(message)
86
+ self.message = message
87
+ self.error_code = error_code
88
+
89
+
90
+ class ConfigurationError(MakePythonError):
91
+ """Raised when configuration loading or saving fails."""
92
+
93
+ pass
94
+
95
+
96
+ class BuildToolNotFoundError(MakePythonError):
97
+ """Raised when no suitable build tool is found."""
98
+
99
+ pass
100
+
101
+
102
+ class TokenConfigurationError(MakePythonError):
103
+ """Raised when PyPI token configuration fails."""
104
+
105
+ pass
106
+
107
+
108
+ class CommandExecutionError(MakePythonError):
109
+ """Raised when command execution fails."""
110
+
111
+ def __init__(
112
+ self, message: str, return_code: int = 1, stdout: str = "", stderr: str = ""
113
+ ):
114
+ super().__init__(message, return_code)
115
+ self.stdout = stdout
116
+ self.stderr = stderr
117
+
118
+
119
+ class BuildTool(Enum):
120
+ """Enumeration of supported build tools.
121
+
122
+ Attributes:
123
+ UV: The uv build tool
124
+ POETRY: The poetry build tool
125
+ HATCH: The hatch build tool
126
+ """
127
+
128
+ UV = "uv"
129
+ POETRY = "poetry"
130
+ HATCH = "hatch"
131
+
132
+
133
+ class CommandType(Enum):
134
+ """Enumeration of command types.
135
+
136
+ Attributes:
137
+ BUILD: Build project command
138
+ CLEAN: Clean build artifacts command
139
+ TEST: Run tests command
140
+ PUBLISH: Publish package command
141
+ BUMP_VERSION: Bump version command
142
+ TOKEN: Manage PyPI token command
143
+ """
144
+
145
+ BUILD = "build"
146
+ CLEAN = "clean"
147
+ TEST = "test"
148
+ PUBLISH = "publish"
149
+ BUMP_VERSION = "bumpversion"
150
+ TOKEN = "token"
151
+
152
+
153
+ @dataclass
154
+ class ProjectInfo:
155
+ """Information about the current Python project.
156
+
157
+ This dataclass holds metadata about a Python project including its name,
158
+ version, build tool, and testing configuration.
159
+
160
+ Attributes:
161
+ name: Project name
162
+ version: Project version string
163
+ build_tool: Detected build tool (optional)
164
+ has_tests: Whether tests directory exists
165
+ has_benchmarks: Whether benchmarks directory exists
166
+ """
167
+
168
+ name: str
169
+ version: str
170
+ build_tool: BuildTool | None = None
171
+ has_tests: bool = False
172
+ has_benchmarks: bool = False
173
+
174
+ @classmethod
175
+ def from_pyproject(cls, directory: Path) -> ProjectInfo | None:
176
+ """Create ProjectInfo from pyproject.toml file.
177
+
178
+ Args:
179
+ directory: Directory containing pyproject.toml file
180
+
181
+ Returns:
182
+ ProjectInfo instance if pyproject.toml exists and is valid, None otherwise
183
+
184
+ Note:
185
+ This method will log warnings for parsing errors but won't raise exceptions.
186
+ Uses caching to improve performance for repeated calls.
187
+ """
188
+ # Check cache first
189
+ cached_info = _project_info_cache.get(directory)
190
+ if cached_info is not None:
191
+ logger.debug(f"Using cached ProjectInfo for {directory}")
192
+ return cached_info
193
+
194
+ pyproject_path = directory / PYPROJECT_FILE_NAME
195
+ if not pyproject_path.exists():
196
+ return None
197
+
198
+ try:
199
+ with pyproject_path.open("rb") as f:
200
+ data = tomllib.load(f)
201
+
202
+ project_data = data.get("project", {})
203
+ name = project_data.get("name", directory.name)
204
+ version = project_data.get("version", "0.1.0")
205
+
206
+ # Detect build tool
207
+ build_tool = None
208
+ if "build-system" in data:
209
+ backend = data["build-system"].get("build-backend", "")
210
+ if backend.startswith("poetry."):
211
+ build_tool = BuildTool.POETRY
212
+ elif backend.startswith("hatchling."):
213
+ build_tool = BuildTool.HATCH
214
+ else:
215
+ build_tool = BuildTool.UV
216
+
217
+ # Check for tests and benchmarks
218
+ tests_dir = directory / "tests"
219
+ has_tests = tests_dir.exists() and tests_dir.is_dir()
220
+
221
+ benchmarks_dir = directory / "tests" / "benchmarks"
222
+ has_benchmarks = benchmarks_dir.exists() and benchmarks_dir.is_dir()
223
+
224
+ project_info = cls(
225
+ name=name,
226
+ version=version,
227
+ build_tool=build_tool,
228
+ has_tests=has_tests,
229
+ has_benchmarks=has_benchmarks,
230
+ )
231
+
232
+ # Cache the result
233
+ _project_info_cache.put(directory, project_info)
234
+
235
+ return project_info
236
+ except Exception as e:
237
+ logger.warning(f"Failed to parse pyproject.toml: {e}")
238
+ return None
239
+
240
+
241
+ @dataclass
242
+ class MakePythonConfig:
243
+ """Configuration for MakePython tool.
244
+
245
+ Manages the configuration settings for the MakePython tool including
246
+ default build tools, verbosity settings, and retry policies.
247
+
248
+ Attributes:
249
+ default_build_tool: Default build tool to use
250
+ auto_detect_tool: Whether to automatically detect build tools
251
+ verbose_output: Enable verbose output
252
+ max_retries: Maximum number of retries for failed operations
253
+ timeout_seconds: Timeout for command execution in seconds
254
+ """
255
+
256
+ default_build_tool: BuildTool = BuildTool.UV
257
+ auto_detect_tool: bool = True
258
+ verbose_output: bool = False
259
+ max_retries: int = DEFAULT_MAX_RETRIES
260
+ timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS
261
+
262
+ def __post_init__(self) -> None:
263
+ """Initialize configuration after creation."""
264
+ self._load_from_file()
265
+ atexit.register(self.save)
266
+
267
+ def _load_from_file(self) -> None:
268
+ """Load configuration from file if it exists.
269
+
270
+ Silently continues if configuration file doesn't exist or is invalid.
271
+ Invalid configurations will use default values.
272
+ """
273
+ if not CONFIG_FILE.exists():
274
+ return
275
+
276
+ try:
277
+ config_data = json.loads(CONFIG_FILE.read_text(encoding=DEFAULT_ENCODING))
278
+ for key, value in config_data.items():
279
+ if hasattr(self, key):
280
+ if key == "default_build_tool":
281
+ try:
282
+ setattr(self, key, BuildTool(value))
283
+ except ValueError:
284
+ logger.warning(
285
+ f"Invalid build tool value '{value}', using default"
286
+ )
287
+ setattr(self, key, self.default_build_tool)
288
+ else:
289
+ setattr(self, key, value)
290
+ except (json.JSONDecodeError, TypeError, AttributeError) as e:
291
+ logger.warning(f"Could not load config from {CONFIG_FILE}: {e}")
292
+ except Exception as e:
293
+ logger.error(f"Unexpected error loading configuration: {e}")
294
+
295
+ def save(self) -> None:
296
+ """Save current configuration to file.
297
+
298
+ Creates parent directories if they don't exist.
299
+
300
+ Raises:
301
+ ConfigurationError: If saving configuration fails
302
+ """
303
+ try:
304
+ CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
305
+ config_dict = {
306
+ "default_build_tool": self.default_build_tool.value,
307
+ "auto_detect_tool": self.auto_detect_tool,
308
+ "verbose_output": self.verbose_output,
309
+ "max_retries": self.max_retries,
310
+ "timeout_seconds": self.timeout_seconds,
311
+ }
312
+ CONFIG_FILE.write_text(
313
+ json.dumps(config_dict, indent=4), encoding=DEFAULT_ENCODING
314
+ )
315
+ logger.debug(f"Configuration saved to {CONFIG_FILE}")
316
+ except OSError as e:
317
+ logger.error(f"Failed to save configuration: {e}")
318
+ raise ConfigurationError(f"Failed to save configuration: {e}") from e
319
+ except Exception as e:
320
+ logger.error(f"Unexpected error saving configuration: {e}")
321
+ raise ConfigurationError(
322
+ f"Unexpected error saving configuration: {e}"
323
+ ) from e
324
+
325
+ @cached_property
326
+ def supported_tools(self) -> list[BuildTool]:
327
+ """Get list of available build tools.
328
+
329
+ Uses caching to avoid repeated system calls for tool detection.
330
+
331
+ Returns:
332
+ List of available BuildTool enums
333
+ """
334
+ return [
335
+ tool
336
+ for tool in BuildTool
337
+ if shutil.which(tool.value)
338
+ ]
339
+
340
+
341
+ class CommandExecutor(Protocol):
342
+ """Protocol for command execution.
343
+
344
+ Defines the interface for executing commands in a consistent manner.
345
+ Implementations should handle subprocess execution with proper error handling.
346
+ """
347
+
348
+ def execute(
349
+ self, command: list[str], cwd: Path
350
+ ) -> subprocess.CompletedProcess[str]:
351
+ """Execute a command in the specified directory.
352
+
353
+ Args:
354
+ command: Command to execute as list of arguments
355
+ cwd: Working directory for command execution
356
+
357
+ Returns:
358
+ CompletedProcess object with command results
359
+
360
+ Raises:
361
+ subprocess.CalledProcessError: If command execution fails
362
+ subprocess.TimeoutExpired: If command times out
363
+ """
364
+ ...
365
+
366
+
367
+ class SubprocessExecutor:
368
+ """Execute commands using subprocess.
369
+
370
+ Provides robust subprocess execution with configurable timeouts,
371
+ proper error handling, and encoding support.
372
+
373
+ Attributes:
374
+ config: MakePython configuration object
375
+ """
376
+
377
+ def __init__(self, config: MakePythonConfig):
378
+ """Initialize the executor with configuration.
379
+
380
+ Args:
381
+ config: MakePython configuration object
382
+ """
383
+ self.config = config
384
+
385
+ def execute(
386
+ self, command: list[str], cwd: Path
387
+ ) -> subprocess.CompletedProcess[str]:
388
+ """Execute command with proper error handling.
389
+
390
+ Args:
391
+ command: Command to execute as list of arguments
392
+ cwd: Working directory for command execution
393
+
394
+ Returns:
395
+ CompletedProcess object with command results
396
+
397
+ Raises:
398
+ CommandExecutionError: For command execution failures
399
+ subprocess.TimeoutExpired: If command times out
400
+ """
401
+ logger.debug(f"Executing command: {' '.join(command)} in {cwd}")
402
+
403
+ try:
404
+ return _execute_command(
405
+ command,
406
+ cwd,
407
+ check=True,
408
+ capture=True,
409
+ timeout=self.config.timeout_seconds,
410
+ fallback_encoding=True,
411
+ )
412
+ except subprocess.CalledProcessError as e:
413
+ raise CommandExecutionError(
414
+ f"Command failed: {' '.join(command)}",
415
+ return_code=e.returncode,
416
+ stdout=e.stdout or "",
417
+ stderr=e.stderr or "",
418
+ ) from e
419
+ except Exception as e:
420
+ raise CommandExecutionError(
421
+ f"Unexpected error executing command: {e}"
422
+ ) from e
423
+
424
+
425
+ class ProjectInfoCache:
426
+ """Simple cache for ProjectInfo objects to improve performance.
427
+
428
+ Caches parsed pyproject.toml data to avoid repeated file I/O operations.
429
+ """
430
+
431
+ def __init__(self, max_size: int = DEFAULT_CACHE_SIZE):
432
+ self._cache: dict[Path, tuple[ProjectInfo, float]] = {}
433
+ self._max_size = max_size
434
+ self._access_order: list[Path] = []
435
+
436
+ def get(self, directory: Path) -> ProjectInfo | None:
437
+ """Get cached ProjectInfo for directory.
438
+
439
+ Args:
440
+ directory: Directory to get cached info for
441
+
442
+ Returns:
443
+ Cached ProjectInfo or None if not found/expired
444
+ """
445
+ if directory not in self._cache:
446
+ return None
447
+
448
+ info, timestamp = self._cache[directory]
449
+ # Cache expires after 5 minutes
450
+ if time.time() - timestamp > DEFAULT_CACHE_EXPIRY:
451
+ del self._cache[directory]
452
+ if directory in self._access_order:
453
+ self._access_order.remove(directory)
454
+ return None
455
+
456
+ # Move to end for LRU
457
+ if directory in self._access_order:
458
+ self._access_order.remove(directory)
459
+ self._access_order.append(directory)
460
+ return info
461
+
462
+ def put(self, directory: Path, info: ProjectInfo) -> None:
463
+ """Store ProjectInfo in cache.
464
+
465
+ Args:
466
+ directory: Directory key
467
+ info: ProjectInfo to cache
468
+ """
469
+ # Remove oldest if cache is full
470
+ if len(self._cache) >= self._max_size:
471
+ oldest = self._access_order.pop(0)
472
+ del self._cache[oldest]
473
+
474
+ self._cache[directory] = (info, time.time())
475
+ self._access_order.append(directory)
476
+
477
+
478
+ # Global cache instance
479
+ _project_info_cache = ProjectInfoCache()
480
+
481
+
482
+ # Removed _get_build_command_from_toml as it duplicates _get_build_command functionality
483
+
484
+
485
+ def _get_build_command(directory: Path, config: MakePythonConfig) -> str | None:
486
+ """Get build command from directory using configuration.
487
+
488
+ Args:
489
+ directory: Directory to search for project configuration
490
+ config: MakePython configuration object
491
+
492
+ Returns:
493
+ Build command string or None if not found
494
+
495
+ Raises:
496
+ BuildToolNotFoundError: If no build tools are available
497
+ """
498
+ project_info = ProjectInfo.from_pyproject(directory)
499
+ if project_info and project_info.build_tool:
500
+ logger.debug(f"Detected build tool: {project_info.build_tool.value}")
501
+ return project_info.build_tool.value
502
+
503
+ # Fallback to available tools
504
+ if config.supported_tools:
505
+ selected_tool = config.supported_tools[0]
506
+ logger.debug(f"Using available build tool: {selected_tool.value}")
507
+ return selected_tool.value
36
508
 
37
- try:
38
- with project_toml.open("rb") as f:
39
- return tomllib.load(f)
40
- except Exception as e:
41
- logger.error(f"Error parsing pyproject.toml: {e}")
42
- return {}
43
-
44
-
45
- def _get_build_command_from_toml(directory: Path) -> str | None:
46
- """Get build command from pyproject.toml."""
47
- logger.debug(f"Parsing pyproject.toml in {directory}")
48
-
49
- project_data = parse_pyproject_toml(directory)
50
- if not project_data:
51
- return None
52
-
53
- if "build-system" in project_data:
54
- build_system = project_data["build-system"]
55
- if "build-backend" in build_system:
56
- build_backend = build_system["build-backend"]
57
- if build_backend.startswith("poetry."):
58
- return "poetry"
59
- elif build_backend.startswith("hatchling."):
60
- return "hatch"
61
- else:
62
- logger.error(f"Unknown build-backend: {build_backend}")
63
- return None
64
-
65
- logger.error("No `build-system` or `build-backend` found in pyproject.toml")
66
- return None
67
-
68
-
69
- def _get_build_command(directory: Path) -> str | None:
70
- """Get build command from directory."""
71
- project_path = directory / "pyproject.toml"
72
- if project_path.is_file():
73
- logger.debug(f"Found pyproject.toml in {directory}")
74
- return _get_build_command_from_toml(directory)
75
-
76
- for command in _BUILD_COMMANDS:
77
- if shutil.which(command):
78
- logger.debug(f"Found build command: {command}")
79
- return command
80
509
  logger.error(f"No build command found in {directory}")
81
- sys.exit(1)
82
- return None
510
+ logger.error("Please install uv, poetry, or hatch")
511
+ raise BuildToolNotFoundError(
512
+ f"No build tools found. Please install uv, poetry, or hatch in {directory}"
513
+ )
83
514
 
84
515
 
85
516
  @dataclass
86
517
  class Command:
518
+ """Represents a command that can be executed."""
519
+
87
520
  name: str
88
521
  alias: str
522
+ command_type: CommandType
89
523
  cmds: list[str] | Callable[..., Any] | None = None
524
+ description: str = ""
525
+
526
+
527
+ def _validate_token_format(token: str) -> tuple[bool, str]:
528
+ """Validate PyPI token format.
529
+
530
+ Args:
531
+ token: Token string to validate
532
+
533
+ Returns:
534
+ Tuple of (is_valid, message)
535
+ """
536
+ if not token:
537
+ return False, "Token cannot be empty"
90
538
 
539
+ if len(token) < 10:
540
+ return False, "Token appears too short to be valid"
91
541
 
92
- def _clean(root_dir: Path = cwd):
93
- _run_command(["rm", "-rf", "dist", "build", "*.egg-info"], root_dir)
542
+ # Basic format validation
543
+ if not (token.startswith("pypi-") or token.startswith("PYPI-")):
544
+ return True, "Warning: Token doesn't start with 'pypi-' (may still be valid)"
94
545
 
546
+ return True, "Token format appears valid"
95
547
 
548
+
549
+ def _show_detailed_help(commands: list[Command]) -> None:
550
+ """Show detailed command descriptions and usage information.
551
+
552
+ Args:
553
+ commands: List of available commands
554
+ """
555
+ print("\nMakePython - Detailed Command Help")
556
+ print("=" * 40)
557
+ print(f"Version: {__version__}")
558
+ print(f"Author: {__author__}")
559
+ print("\nAvailable Commands:")
560
+ print("-" * 20)
561
+
562
+ # Group commands by type for better organization
563
+ command_groups = {}
564
+ for command in commands:
565
+ group = command.command_type.value
566
+ if group not in command_groups:
567
+ command_groups[group] = []
568
+ command_groups[group].append(command)
569
+
570
+ for group_name, group_commands in command_groups.items():
571
+ print(f"\n{group_name.upper()} COMMANDS:")
572
+ for command in group_commands:
573
+ aliases = f" ({command.alias})" if command.alias != command.name else ""
574
+ print(f" {command.name}{aliases:<8} - {command.description}")
575
+
576
+ print("\nUSAGE EXAMPLES:")
577
+ print(" makepython build # Build the project")
578
+ print(" makepython test # Run tests")
579
+ print(" makepython publish # Publish to PyPI")
580
+ print(" makepython token # Configure PyPI token")
581
+ print(" makepython clean # Clean build artifacts")
582
+
583
+ print("\nOPTIONS:")
584
+ print(" --debug, -d Enable debug output")
585
+ print(" --verbose, -v Enable verbose output")
586
+ print(" --quiet, -q Suppress most messages")
587
+ print(" --version Show version information")
588
+ print(" --help-commands Show this detailed help")
589
+
590
+ print("\nFor more information, visit: https://github.com/pysfi/makepython")
591
+
592
+
593
+ def _clean(root_dir: Path = CURRENT_WORKING_DIR) -> None:
594
+ """Clean build artifacts and temporary files.
595
+
596
+ Args:
597
+ root_dir: Root directory to clean
598
+ """
599
+ logger.info(f"Cleaning build artifacts in {root_dir}")
600
+
601
+ # Clean common build directories
602
+ targets = ["dist", "build", "*.egg-info"]
603
+ cleaned_count = 0
604
+ failed_count = 0
605
+
606
+ for target in targets:
607
+ try:
608
+ _execute_command([*CLEAN_COMMANDS, target], root_dir, check=False)
609
+ cleaned_count += 1
610
+ except Exception as e:
611
+ logger.warning(f"Failed to clean {target}: {e}")
612
+ failed_count += 1
613
+ continue
614
+
615
+ logger.info(f"Clean operation completed: {cleaned_count} succeeded, {failed_count} failed")
616
+
617
+
618
+ def _dispatch_command(command: Command, *, realtime: bool = False) -> None:
619
+ """Execute a command based on its type.
620
+
621
+ Args:
622
+ command: Command object to execute
623
+ realtime: Whether to stream output in real-time
624
+ """
625
+ if callable(command.cmds):
626
+ command.cmds()
627
+ elif isinstance(command.cmds, list):
628
+ # Use realtime output for test commands or if explicitly requested
629
+ use_realtime = realtime or command.command_type == CommandType.TEST
630
+ _run_command(command.cmds, CURRENT_WORKING_DIR, realtime=use_realtime)
631
+ else:
632
+ logger.debug("No preset commands found for this command type")
633
+
634
+
635
+ def _handle_publish_command(build_command: str) -> None:
636
+ """Handle publish command with token validation.
637
+
638
+ Args:
639
+ build_command: Build command string
640
+ """
641
+ if not _check_pypi_token(build_command):
642
+ logger.warning(
643
+ "PyPI token not configured. Starting configuration process..."
644
+ )
645
+ _set_token(build_command)
646
+ _run_command([build_command, "publish"], CURRENT_WORKING_DIR)
647
+
648
+
96
649
  def main() -> None:
97
- # Get build command
98
- build_command = _get_build_command(cwd) or ""
99
- commands = [
100
- Command(name="build", alias="b", cmds=[build_command, "build"]),
101
- Command(name="bumpversion", alias="bump", cmds=["bumpversion", "patch", "--tag"]),
102
- Command(name="clean", alias="c", cmds=_clean),
103
- Command(name="publish", alias="p"), # No preset commands
104
- Command(name="test", alias="t", cmds=lambda: os.system("pytest")),
105
- Command(name="test-benchmark", alias="tb", cmds=lambda: os.system("pytest -m benchmark")),
106
- Command(name="test-coverage", alias="tc", cmds=lambda: os.system("pytest --cov=sfi")),
107
- Command(name="token", alias="tk", cmds=lambda: _set_token(build_command)),
108
- ]
109
- command_dict = {command.name: command for command in commands}
110
- command_dict.update({command.alias: command for command in commands})
111
- choices = [command.alias for command in commands]
112
- choices.extend([command.name for command in commands])
113
-
114
- # Parse args
115
- parser = argparse.ArgumentParser(description="Make Python")
116
- parser.add_argument("--debug", "-d", action="store_true", help="Enable debug mode")
117
- parser.add_argument("command", type=str, choices=choices, help=f"Command to run, options: {choices}")
118
- args = parser.parse_args()
119
- if args.debug:
120
- logger.setLevel(logging.DEBUG)
121
-
122
- logger.debug(f"Using build command: {build_command}")
123
- command = command_dict.get(args.command)
124
- if command:
125
- if callable(command.cmds):
126
- command.cmds()
127
- elif isinstance(command.cmds, list):
128
- _run_command(command.cmds, cwd)
650
+ """Main entry point for MakePython tool.
651
+
652
+ Parses command line arguments, loads configuration, detects build tools,
653
+ and executes the requested command.
654
+
655
+ Raises:
656
+ SystemExit: If command execution fails or unknown command is provided
657
+ """
658
+ try:
659
+ # Initialize configuration
660
+ config = MakePythonConfig()
661
+
662
+ # Get build command
663
+ try:
664
+ build_command = _get_build_command(CURRENT_WORKING_DIR, config) or ""
665
+ except BuildToolNotFoundError as e:
666
+ logger.error(str(e))
667
+ sys.exit(1)
668
+
669
+ # Create commands with proper structure
670
+ commands = [
671
+ Command(
672
+ name="build",
673
+ alias="b",
674
+ command_type=CommandType.BUILD,
675
+ cmds=[build_command, "build"],
676
+ description="Build the project package",
677
+ ),
678
+ Command(
679
+ name="bumpversion",
680
+ alias="bump",
681
+ command_type=CommandType.BUMP_VERSION,
682
+ cmds=["bumpversion", "patch", "--tag"],
683
+ description="Bump project version (patch/minor/major)",
684
+ ),
685
+ Command(
686
+ name="clean",
687
+ alias="c",
688
+ command_type=CommandType.CLEAN,
689
+ cmds=_clean,
690
+ description="Clean build artifacts and temporary files",
691
+ ),
692
+ Command(
693
+ name="publish",
694
+ alias="p",
695
+ command_type=CommandType.PUBLISH,
696
+ description="Publish package to PyPI (requires token)",
697
+ ),
698
+ Command(
699
+ name="test",
700
+ alias="t",
701
+ command_type=CommandType.TEST,
702
+ cmds=["pytest", "-n", "8"],
703
+ description="Run tests with parallel execution",
704
+ ),
705
+ Command(
706
+ name="test-benchmark",
707
+ alias="tb",
708
+ command_type=CommandType.TEST,
709
+ cmds=["pytest", "-m", "benchmark", "-n", "8"],
710
+ description="Run benchmark tests specifically",
711
+ ),
712
+ Command(
713
+ name="test-coverage",
714
+ alias="tc",
715
+ command_type=CommandType.TEST,
716
+ cmds=["pytest", "--cov=sfi", "-n", "8"],
717
+ description="Run tests with coverage reporting",
718
+ ),
719
+ Command(
720
+ name="token",
721
+ alias="tk",
722
+ command_type=CommandType.TOKEN,
723
+ cmds=lambda: _set_token(build_command),
724
+ description="Configure PyPI token for publishing",
725
+ ),
726
+ ]
727
+ command_dict = {command.name: command for command in commands}
728
+ command_dict.update({command.alias: command for command in commands})
729
+ choices = [command.alias for command in commands]
730
+ choices.extend([command.name for command in commands])
731
+
732
+ # Parse arguments
733
+ parser = argparse.ArgumentParser(
734
+ prog="makepython",
735
+ description="Automated Python project building and management tool",
736
+ epilog="""Examples:
737
+ makepython build Build the project
738
+ makepython test Run tests with real-time output (default)
739
+ makepython test -r Run tests with real-time output (explicit)
740
+ makepython publish Publish to PyPI
741
+ makepython clean Clean build artifacts
742
+
743
+ Note: Test commands (test, test-benchmark, test-coverage) automatically use real-time output.
744
+ Use --realtime/-r for other commands if you want to see output as it happens.""",
745
+ formatter_class=argparse.RawDescriptionHelpFormatter,
746
+ )
747
+
748
+ # Add argument groups for better organization
749
+ info_group = parser.add_argument_group("Information Options")
750
+ info_group.add_argument(
751
+ "--version",
752
+ action="version",
753
+ version=f"MakePython v{__version__}",
754
+ help="Show version information and exit",
755
+ )
756
+ info_group.add_argument(
757
+ "--help-commands",
758
+ action="store_true",
759
+ help="Show detailed command descriptions and exit",
760
+ )
761
+
762
+ config_group = parser.add_argument_group("Configuration Options")
763
+ config_group.add_argument(
764
+ "--config-path", type=Path, help="Specify custom configuration file path"
765
+ )
766
+ config_group.add_argument(
767
+ "--build-tool",
768
+ choices=[tool.value for tool in BuildTool],
769
+ help="Force specific build tool (uv, poetry, hatch)",
770
+ )
771
+
772
+ logging_group = parser.add_argument_group("Logging Options")
773
+ logging_group.add_argument(
774
+ "--debug",
775
+ "-d",
776
+ action="store_true",
777
+ help="Enable debug mode with verbose output",
778
+ )
779
+ logging_group.add_argument(
780
+ "--verbose", "-v", action="store_true", help="Enable verbose output"
781
+ )
782
+ logging_group.add_argument(
783
+ "--quiet", "-q", action="store_true", help="Suppress most output messages"
784
+ )
785
+
786
+ output_group = parser.add_argument_group("Output Options")
787
+ output_group.add_argument(
788
+ "--realtime",
789
+ "-r",
790
+ action="store_true",
791
+ help="Stream command output in real-time (useful for tests)",
792
+ )
793
+
794
+ parser.add_argument(
795
+ "command",
796
+ nargs="?",
797
+ type=str,
798
+ choices=choices,
799
+ help=f"Command to run. Available: {', '.join(choices)}",
800
+ )
801
+
802
+ args = parser.parse_args()
803
+
804
+ # Handle help commands option
805
+ if hasattr(args, "help_commands") and args.help_commands:
806
+ _show_detailed_help(commands)
807
+ return
808
+
809
+ # Validate that a command was provided
810
+ if not args.command:
811
+ parser.print_help()
812
+ logger.error("No command specified. Use --help for usage information.")
813
+ sys.exit(1)
814
+
815
+ # Set logging level
816
+ if args.quiet:
817
+ logger.setLevel(logging.ERROR)
818
+ config.verbose_output = False
819
+ elif args.debug:
820
+ logger.setLevel(logging.DEBUG)
821
+ config.verbose_output = True
822
+ elif args.verbose:
823
+ logger.setLevel(logging.INFO)
824
+ config.verbose_output = True
825
+
826
+ logger.debug(f"Using build command: {build_command}")
827
+ logger.debug(f"Working directory: {CURRENT_WORKING_DIR}")
828
+
829
+ # Execute command
830
+ command = command_dict.get(args.command)
831
+ if command:
832
+ logger.info(f"Executing command: {command.name} ({command.description})")
833
+ _dispatch_command(command, realtime=args.realtime)
129
834
  else:
130
- logger.debug("No preset commands found")
131
- else:
132
- logger.error(f"Unknown command: {args.command}")
835
+ logger.error(f"Unknown command: {args.command}")
836
+ sys.exit(1)
837
+
838
+ # Special handling for publish command
839
+ if args.command in {"publish", "p"}:
840
+ _handle_publish_command(build_command)
841
+
842
+ except KeyboardInterrupt:
843
+ logger.info("Operation cancelled by user")
844
+ sys.exit(130) # Standard exit code for Ctrl+C
845
+ except MakePythonError as e:
846
+ logger.error(f"MakePython error: {e.message}")
847
+ sys.exit(e.error_code)
848
+ except Exception as e:
849
+ logger.error(f"Unexpected error: {e}")
133
850
  sys.exit(1)
134
851
 
135
- if args.command in {"publish", "p"}:
136
- if not _check_pypi_token(build_command):
137
- _set_token(build_command)
138
- _run_command([build_command, "publish"], cwd)
139
-
140
852
 
141
853
  def _set_token(build_command: str, show_header: bool = True) -> None:
142
- """Set PyPI token for the specified build command."""
854
+ """Set PyPI token for the specified build command.
855
+
856
+ Args:
857
+ build_command: The build tool to configure (uv, poetry, hatch)
858
+ show_header: Whether to show the header message
859
+
860
+ Raises:
861
+ TokenConfigurationError: If unknown build command is provided or token setting fails
862
+ """
143
863
  if show_header:
144
864
  logger.info(f"Setting PyPI token for {build_command}...")
865
+ logger.info(
866
+ "Note: Your token will be stored securely in the appropriate configuration files."
867
+ )
145
868
 
146
- if build_command.lower() not in _BUILD_COMMANDS:
147
- logger.error(f"Unknown build command: {build_command}")
148
- logger.error(f"Please use `{'/'.join(_BUILD_COMMANDS)}`")
149
- sys.exit(1)
869
+ if build_command.lower() not in [tool.value for tool in BuildTool]:
870
+ error_msg = f"Unknown build command: {build_command}. Please use one of: {[tool.value for tool in BuildTool]}"
871
+ logger.error(error_msg)
872
+ raise TokenConfigurationError(error_msg)
150
873
 
151
874
  token = input("Enter your PyPI token (leave empty to cancel): ").strip()
152
875
  if not token:
153
- logger.info("Invalid token, cancelled.")
876
+ logger.info("Operation cancelled - no token provided.")
154
877
  return
155
878
 
156
- if build_command == "uv":
157
- _set_uv_token(token)
158
- elif build_command == "poetry":
159
- _set_poetry_token(token)
160
- elif build_command == "hatch":
161
- _set_hatch_token(token)
162
- else:
163
- logger.error(f"Unknown build command: {build_command}")
164
- sys.exit(1)
879
+ # Validate token format
880
+ is_valid, message = _validate_token_format(token)
881
+ if not is_valid:
882
+ logger.error(f"Invalid token: {message}")
883
+ raise TokenConfigurationError(message)
884
+ elif "Warning" in message:
885
+ logger.warning(message)
165
886
 
887
+ try:
888
+ build_tool = BuildTool(build_command)
889
+ _set_build_tool_token(token, build_tool)
890
+ logger.info("PyPI token configured successfully!")
891
+ logger.info("You can now use 'makepython publish' to publish your package.")
892
+ except Exception as e:
893
+ error_msg = f"Failed to set token: {e}"
894
+ logger.error(error_msg)
895
+ raise TokenConfigurationError(error_msg) from e
166
896
 
167
- def _set_uv_token(token: str) -> None:
168
- """Set PyPI token for uv."""
169
- _write_to_env_file("UV_PUBLISH_TOKEN", token)
170
897
 
171
- # Write to `uv.toml`
172
- config_path = Path.home() / ".config" / "uv" / "uv.toml"
173
- config_path.parent.mkdir(parents=True, exist_ok=True)
174
- content = f"""[publish]
175
- token = "{token}"
176
- """
177
- config_path.write_text(content)
178
- logger.info(f"Token saved to {config_path}")
898
+ def _save_token_to_file(filepath: Path, content: str, description: str) -> None:
899
+ """Save token content to file with proper error handling.
900
+
901
+ Args:
902
+ filepath: Path to save the file
903
+ content: Content to write
904
+ description: Description for logging
905
+
906
+ Raises:
907
+ OSError: If file operation fails
908
+ """
909
+ try:
910
+ filepath.parent.mkdir(parents=True, exist_ok=True)
911
+ filepath.write_text(content, encoding=DEFAULT_ENCODING)
912
+ logger.info(f"Token saved to {filepath} ({description})")
913
+ except OSError as e:
914
+ logger.error(f"Failed to save token to {filepath}: {e}")
915
+ raise
179
916
 
180
917
 
181
- def _set_poetry_token(token: str) -> None:
182
- """Set PyPI token for poetry."""
183
- _write_to_env_file("POETRY_PYPI_TOKEN_PYPI", token)
184
- _run_command(["poetry", "config", "pypi-token.pypi", token], cwd)
185
- logger.info("Token saved to Poetry configuration.")
918
+ def _set_build_tool_token(token: str, build_tool: BuildTool) -> None:
919
+ """Set PyPI token for the specified build tool.
186
920
 
921
+ Args:
922
+ token: PyPI token to set
923
+ build_tool: Build tool to configure
187
924
 
188
- def _set_hatch_token(token: str) -> None:
189
- """Set PyPI token for hatch."""
190
- pypirc_path = Path.home() / ".pypirc"
191
- pypirc_content = f"""[pypi]
925
+ Raises:
926
+ TokenConfigurationError: If token setting fails
927
+ """
928
+ try:
929
+ if build_tool == BuildTool.UV:
930
+ # Set UV environment variable and config file
931
+ _write_to_env_file("UV_PUBLISH_TOKEN", token)
932
+ config_content = f"""[publish]
933
+ token = "{token}"
934
+ """
935
+ config_path = Path.home() / ".config" / "uv" / "uv.toml"
936
+ _save_token_to_file(config_path, config_content, "UV config")
937
+
938
+ elif build_tool == BuildTool.POETRY:
939
+ # Set Poetry environment variable and config
940
+ _write_to_env_file("POETRY_PYPI_TOKEN_PYPI", token)
941
+ _run_command(
942
+ ["poetry", "config", "pypi-token.pypi", token], CURRENT_WORKING_DIR
943
+ )
944
+ logger.debug("Token saved to Poetry configuration.")
945
+
946
+ elif build_tool == BuildTool.HATCH:
947
+ # Write to .pypirc
948
+ pypirc_content = f"""[pypi]
192
949
  repository = https://upload.pypi.org/legacy/
193
950
  username = __token__
194
951
  password = {token}
195
952
  """
196
- pypirc_path.write_text(pypirc_content)
197
- logger.info(f"Token saved to {pypirc_path}")
953
+ pypirc_path = Path.home() / ".pypirc"
954
+ _save_token_to_file(pypirc_path, pypirc_content, "PyPI RC")
955
+ except Exception as e:
956
+ raise TokenConfigurationError(f"Failed to set token for {build_tool.value}: {e}") from e
198
957
 
199
958
 
200
- def _check_pypi_token(build_command: str) -> bool:
201
- """Check if PyPI token is configured before publishing."""
202
- logger.info("Checking PyPI token configuration...")
959
+ def _check_uv_token() -> bool:
960
+ """Check if UV PyPI token is configured."""
961
+ # Check environment variables
962
+ token_env_vars = ["UV_PUBLISH_TOKEN", "PYPI_API_TOKEN"]
963
+ for var in token_env_vars:
964
+ if os.getenv(var):
965
+ logger.debug(f"Found PyPI token in environment variable: {var}")
966
+ return True
967
+
968
+ # Check config file
969
+ config_path = Path.home() / ".config" / "uv" / "uv.toml"
970
+ if config_path.exists():
971
+ logger.debug(f"Found uv config file: {config_path}")
972
+ return True
973
+
974
+ return False
975
+
203
976
 
204
- token_found = False
205
- if build_command == "uv":
206
- # Check for uv publish token
207
- token_env_vars = ["UV_PUBLISH_TOKEN", "PYPI_API_TOKEN"]
208
- for var in token_env_vars:
209
- if os.getenv(var):
210
- logger.info(f"Found PyPI token in environment variable: {var}")
211
- token_found = True
212
- break
213
-
214
- # Check for config file
215
- config_path = Path.home() / ".config" / "uv" / "uv.toml"
216
- if config_path.exists():
217
- logger.info(f"Found uv config file: {config_path}")
218
- token_found = True
219
-
220
- elif build_command == "poetry":
221
- # Check for poetry token
222
- if os.getenv("POETRY_PYPI_TOKEN_PYPI"):
223
- logger.info("Found PyPI token in POETRY_PYPI_TOKEN_PYPI environment variable")
224
- token_found = True
225
-
226
- # Check for poetry config
227
- result = subprocess.run(
977
+ def _check_poetry_token() -> bool:
978
+ """Check if Poetry PyPI token is configured."""
979
+ # Check environment variable
980
+ if os.getenv("POETRY_PYPI_TOKEN_PYPI"):
981
+ logger.debug("Found PyPI token in POETRY_PYPI_TOKEN_PYPI environment variable")
982
+ return True
983
+
984
+ # Check Poetry config
985
+ try:
986
+ result = _execute_command(
228
987
  ["poetry", "config", "pypi-token.pypi"],
229
- capture_output=True,
230
- text=True,
988
+ CURRENT_WORKING_DIR,
989
+ check=False,
990
+ capture=True,
231
991
  )
232
992
  if result.stdout.strip() and result.stdout.strip() != "None":
233
- logger.info("Found PyPI token in Poetry configuration")
234
- token_found = True
993
+ logger.debug("Found PyPI token in Poetry configuration")
994
+ return True
995
+ except Exception as e:
996
+ logger.debug(f"Error checking Poetry token: {e}")
997
+ return False
998
+
999
+
1000
+ def _check_hatch_token() -> bool:
1001
+ """Check if Hatch PyPI token is configured."""
1002
+ pypirc_path = Path.home() / ".pypirc"
1003
+ if pypirc_path.exists():
1004
+ logger.debug(f"Found .pypirc file: {pypirc_path}")
1005
+ return True
1006
+ return False
235
1007
 
236
- elif build_command == "hatch":
237
- # Check for .pypirc
238
- pypirc_path = Path.home() / ".pypirc"
239
- if pypirc_path.exists():
240
- logger.info(f"Found .pypirc file: {pypirc_path}")
241
- token_found = True
242
1008
 
243
- return token_found
1009
+ def _check_build_tool_token(build_tool: BuildTool) -> bool:
1010
+ """Check if PyPI token is configured for the specified build tool.
244
1011
 
1012
+ Args:
1013
+ build_tool: Build tool to check
1014
+
1015
+ Returns:
1016
+ True if token is found, False otherwise
1017
+ """
1018
+ check_functions = {
1019
+ BuildTool.UV: _check_uv_token,
1020
+ BuildTool.POETRY: _check_poetry_token,
1021
+ BuildTool.HATCH: _check_hatch_token,
1022
+ }
1023
+
1024
+ checker = check_functions.get(build_tool)
1025
+ if checker:
1026
+ return checker()
1027
+
1028
+ logger.error(f"Unknown build tool for token checking: {build_tool}")
1029
+ return False
1030
+
1031
+
1032
+ def _check_pypi_token(build_command: str) -> bool:
1033
+ """Check if PyPI token is configured before publishing.
1034
+
1035
+ Args:
1036
+ build_command: Build tool name (uv, poetry, hatch)
1037
+
1038
+ Returns:
1039
+ True if token is configured, False otherwise
1040
+ """
1041
+ logger.info("Checking PyPI token configuration...")
245
1042
 
246
- def _run_command(cmd: list[str], directory: Path) -> None:
247
- """Run a command in the specified directory."""
248
- logger.debug(f"Running command: {' '.join(cmd)}")
249
1043
  try:
250
- result = subprocess.run(cmd, cwd=directory, capture_output=True, text=True)
1044
+ build_tool = BuildTool(build_command.lower())
1045
+ return _check_build_tool_token(build_tool)
1046
+ except ValueError:
1047
+ logger.error(f"Unknown build command for token checking: {build_command}")
1048
+ return False
1049
+
1050
+
1051
+ def _execute_command(
1052
+ cmd: list[str],
1053
+ directory: Path,
1054
+ *,
1055
+ check: bool = True,
1056
+ capture: bool = True,
1057
+ timeout: int | None = None,
1058
+ fallback_encoding: bool = True,
1059
+ realtime: bool = False,
1060
+ ) -> subprocess.CompletedProcess[str]:
1061
+ """Execute command with comprehensive error handling.
1062
+
1063
+ Args:
1064
+ cmd: Command to execute as list of arguments
1065
+ directory: Working directory
1066
+ check: Whether to raise CalledProcessError on non-zero exit
1067
+ capture: Whether to capture stdout/stderr (must be False for realtime)
1068
+ timeout: Timeout in seconds (None for no timeout)
1069
+ fallback_encoding: Whether to try fallback encoding on Unicode errors
1070
+ realtime: Whether to stream output in real-time (overrides capture)
1071
+
1072
+ Returns:
1073
+ CompletedProcess object with command results
1074
+
1075
+ Raises:
1076
+ SystemExit: If command fails to execute and check=True
1077
+ subprocess.CalledProcessError: If command fails and check=True
1078
+ subprocess.TimeoutExpired: If command times out
1079
+ """
1080
+ logger.debug(f"Running command: {' '.join(cmd)} in {directory}")
1081
+
1082
+ # If realtime output is requested, disable capture
1083
+ if realtime:
1084
+ capture = False
1085
+
1086
+ def _run_with_encoding(encoding: str) -> subprocess.CompletedProcess[str]:
1087
+ """Run command with specified encoding."""
1088
+ return subprocess.run(
1089
+ cmd,
1090
+ cwd=directory,
1091
+ capture_output=capture,
1092
+ text=capture,
1093
+ encoding=encoding if capture else None,
1094
+ errors="replace" if capture else None,
1095
+ check=check,
1096
+ timeout=timeout,
1097
+ )
1098
+
1099
+ try:
1100
+ result = _run_with_encoding(DEFAULT_ENCODING)
1101
+ if result.stdout and capture:
1102
+ print(result.stdout)
1103
+ if result.stderr and capture:
1104
+ print(result.stderr, file=sys.stderr)
1105
+ return result
1106
+
251
1107
  except subprocess.CalledProcessError as e:
252
- logger.error(f"Command failed with exit code {e.returncode}")
253
- sys.exit(e.returncode)
1108
+ logger.error(f"Command failed with exit code {e.returncode}: {' '.join(cmd)}")
1109
+ if e.stdout:
1110
+ logger.error(f"STDOUT: {e.stdout}")
1111
+ if e.stderr:
1112
+ logger.error(f"STDERR: {e.stderr}")
1113
+ raise
1114
+
1115
+ except subprocess.TimeoutExpired:
1116
+ logger.error(f"Command timed out: {' '.join(cmd)}")
1117
+ raise
1118
+
1119
+ except UnicodeDecodeError as e:
1120
+ if not fallback_encoding or realtime:
1121
+ logger.error(f"Encoding error while processing command output: {e}")
1122
+ raise
1123
+
1124
+ logger.warning(f"Encoding error, trying fallback encoding: {e}")
1125
+ try:
1126
+ result = _run_with_encoding("gbk" if IS_WINDOWS else "utf-8")
1127
+ if result.stdout and capture:
1128
+ print(result.stdout)
1129
+ if result.stderr and capture:
1130
+ print(result.stderr, file=sys.stderr)
1131
+ return result
1132
+ except Exception as fallback_error:
1133
+ logger.error(f"Fallback encoding also failed: {fallback_error}")
1134
+ raise
254
1135
 
255
- if result.stdout:
256
- print(result.stdout)
257
- if result.stderr:
258
- print(result.stderr, file=sys.stderr)
1136
+ except Exception as e:
1137
+ logger.error(f"Unexpected error executing command {' '.join(cmd)}: {e}")
1138
+ raise
1139
+
1140
+
1141
+ def _run_command(cmd: list[str], directory: Path, *, realtime: bool = False) -> None:
1142
+ """Run a command in specified directory with proper error handling.
1143
+
1144
+ Args:
1145
+ cmd: Command to execute as list of arguments
1146
+ directory: Directory to run the command in
1147
+ realtime: Whether to stream output in real-time
1148
+
1149
+ Raises:
1150
+ SystemExit: If command fails to execute
1151
+ ValueError: If inputs are invalid
1152
+ """
1153
+ # Validate inputs
1154
+ if not directory.exists() or not directory.is_dir():
1155
+ raise ValueError(f"Invalid directory: {directory}")
1156
+
1157
+ if not cmd or not all(isinstance(c, str) for c in cmd):
1158
+ raise ValueError("Invalid command list")
1159
+
1160
+ try:
1161
+ _execute_command(cmd, directory, check=True, realtime=realtime)
1162
+ except subprocess.CalledProcessError as e:
1163
+ sys.exit(e.returncode)
1164
+ except subprocess.TimeoutExpired:
1165
+ sys.exit(124) # Standard exit code for timeout
1166
+ except Exception as e:
1167
+ logger.error(f"Command execution failed: {e}")
1168
+ sys.exit(1)
259
1169
 
260
1170
 
261
1171
  def _write_to_env_file(key: str, value: str) -> None:
262
- """Write key-value pair to environment file."""
263
- if is_windows:
264
- subprocess.run(["setx", key, value], shell=True)
1172
+ """Write key-value pair to environment file.
1173
+
1174
+ Sets environment variables either through system utilities (Windows)
1175
+ or shell configuration files (Unix-like systems).
1176
+
1177
+ Args:
1178
+ key: Environment variable name
1179
+ value: Environment variable value
1180
+ """
1181
+ if IS_WINDOWS:
1182
+ try:
1183
+ _execute_command(
1184
+ ["setx", key, value],
1185
+ CURRENT_WORKING_DIR,
1186
+ check=True,
1187
+ capture=False,
1188
+ )
1189
+ logger.info(f"Environment variable {key} set successfully")
1190
+ except Exception as e:
1191
+ logger.warning(f"Failed to set environment variable {key}: {e}")
1192
+ logger.info("You may need to restart your shell for changes to take effect")
265
1193
  else:
266
1194
  _write_to_shell_config(f"export {key}='{value}'")
267
1195
 
268
1196
 
269
1197
  def _get_shell_config_path() -> Path:
270
- """Get the appropriate shell config file based on the current shell."""
1198
+ """Get the appropriate shell config file based on the current shell.
1199
+
1200
+ Detects the user's shell and returns the corresponding configuration file path.
1201
+ Defaults to .bashrc for bash/zsh shells.
1202
+
1203
+ Returns:
1204
+ Path to the appropriate shell configuration file
1205
+ """
271
1206
  # Try to detect the shell
272
1207
  shell = os.getenv("SHELL", "")
273
1208
  if "zsh" in shell:
274
1209
  return Path.home() / ".zshrc"
275
1210
  else:
276
1211
  # Default to .bashrc
277
- return Path.home() / ".bashrc"
1212
+ return Path.home() / DEFAULT_SHELL_CONFIG
278
1213
 
279
1214
 
280
1215
  def _write_to_shell_config(content: str) -> None:
281
- """Write content to ~/.bashrc, replacing existing entries if they exist.
1216
+ """Write content to shell configuration file, replacing existing entries.
1217
+
1218
+ Updates shell configuration files by removing existing export statements
1219
+ for the same variable and adding the new content.
1220
+
1221
+ Args:
1222
+ content: Content to write (should be export statements)
282
1223
 
283
- Deprecated: Use _write_to_shell_config instead.
1224
+ Raises:
1225
+ ValueError: If export statement format is invalid
1226
+ OSError: If file operation fails
284
1227
  """
285
1228
  config_path = _get_shell_config_path()
286
1229
  if not config_path.exists():
@@ -292,36 +1235,37 @@ def _write_to_shell_config(content: str) -> None:
292
1235
  var_name = None
293
1236
  for line in content.strip().split("\n"):
294
1237
  if line.startswith("export ") and "=" in line:
295
- var_name = line.split("=")[0].replace("export ", "").strip()
1238
+ var_name = line.split("=")[0].removeprefix("export ").strip()
296
1239
  break
297
1240
 
298
1241
  if not var_name:
299
- logger.error("Invalid export statement format. Expected: export VARIABLE_NAME=value")
300
- return
1242
+ raise ValueError(
1243
+ "Invalid export statement format. Expected: export VARIABLE_NAME=value"
1244
+ )
301
1245
 
302
1246
  # Read existing content
303
- existing_lines = config_path.read_text(encoding="utf-8").split("\n")
1247
+ existing_lines = config_path.read_text(encoding=DEFAULT_ENCODING).split("\n")
304
1248
 
305
1249
  # Find and remove existing export statements for this variable
306
1250
  new_lines = []
307
1251
  found_existing = False
308
1252
  for line in existing_lines:
309
1253
  # Check if this line exports the same variable
310
- if line.strip().startswith(f"export {var_name}=") or line.strip().startswith(f"export {var_name} ="):
1254
+ if line.strip().startswith(f"export {var_name}=") or line.strip().startswith(
1255
+ f"export {var_name} ="
1256
+ ):
311
1257
  found_existing = True
312
1258
  continue
313
1259
  new_lines.append(line)
314
1260
 
315
1261
  if found_existing:
316
- logger.info(f"Found existing export statement for {var_name}, replacing it...")
1262
+ logger.debug(f"Found existing export statement for {var_name}, replacing it...")
317
1263
 
318
1264
  # Add new content
319
1265
  new_lines.append(content.strip())
320
1266
 
321
1267
  # Write back to file
322
- config_path.write_text("\n".join(new_lines), encoding="utf-8")
1268
+ config_path.write_text("\n".join(new_lines), encoding=DEFAULT_ENCODING)
323
1269
 
324
1270
  logger.info(f"Content written to {config_path}")
325
1271
  logger.info(f"Run `source {config_path}` to apply the changes")
326
- logger.info(f"Run `cat {config_path}` to view the content")
327
- logger.info(f"Run `cat {config_path} | grep 'export'` to view the exported variables")