pysfi 0.1.13__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.
@@ -2,22 +2,40 @@
2
2
 
3
3
  This module provides a command-line interface for common Python development tasks
4
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
5
23
  """
6
24
 
7
25
  from __future__ import annotations
8
26
 
9
- __version__ = "0.2.0"
10
- __author__ = "pysfi Developers"
11
- __email__ = "developers@pysfi.org"
27
+ __version__: Final[str] = "0.2.0"
28
+ __author__: Final[str] = "pysfi Developers"
29
+ __email__: Final[str] = "developers@pysfi.org"
12
30
  import argparse
13
31
  import atexit
14
- import contextlib
15
32
  import json
16
33
  import logging
17
34
  import os
18
35
  import shutil
19
36
  import subprocess
20
37
  import sys
38
+ import time
21
39
  from dataclasses import dataclass
22
40
  from enum import Enum
23
41
  from functools import cached_property
@@ -32,23 +50,80 @@ else:
32
50
 
33
51
  # Platform detection
34
52
  IS_WINDOWS: Final[bool] = sys.platform == "win32"
35
- IS_LINUX: Final[bool] = sys.platform == "linux"
36
- IS_MACOS: Final[bool] = sys.platform == "darwin"
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"]
37
69
 
38
70
  # Logging setup
39
- logging.basicConfig(
40
- level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
41
- )
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"
73
+
74
+ logging.basicConfig(level=logging.INFO, format=LOG_FORMAT, datefmt=LOG_DATE_FORMAT)
42
75
  logger = logging.getLogger(__name__)
43
76
  CURRENT_WORKING_DIR: Final[Path] = Path.cwd()
44
77
 
45
- # Supported build tools
46
- _BUILD_COMMANDS: Final[list[str]] = ["uv", "poetry", "hatch"]
47
- CONFIG_FILE: Final[Path] = Path.home() / ".pysfi" / "makepython.json"
78
+ CONFIG_FILE: Final[Path] = Path.home() / CONFIG_DIR_NAME / CONFIG_FILE_NAME
79
+
80
+
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
48
117
 
49
118
 
50
119
  class BuildTool(Enum):
51
- """Enumeration of supported build tools."""
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
+ """
52
127
 
53
128
  UV = "uv"
54
129
  POETRY = "poetry"
@@ -56,7 +131,16 @@ class BuildTool(Enum):
56
131
 
57
132
 
58
133
  class CommandType(Enum):
59
- """Enumeration of command types."""
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
+ """
60
144
 
61
145
  BUILD = "build"
62
146
  CLEAN = "clean"
@@ -68,7 +152,18 @@ class CommandType(Enum):
68
152
 
69
153
  @dataclass
70
154
  class ProjectInfo:
71
- """Information about the current Python project."""
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
+ """
72
167
 
73
168
  name: str
74
169
  version: str
@@ -78,8 +173,25 @@ class ProjectInfo:
78
173
 
79
174
  @classmethod
80
175
  def from_pyproject(cls, directory: Path) -> ProjectInfo | None:
81
- """Create ProjectInfo from pyproject.toml file."""
82
- pyproject_path = directory / "pyproject.toml"
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
83
195
  if not pyproject_path.exists():
84
196
  return None
85
197
 
@@ -109,13 +221,18 @@ class ProjectInfo:
109
221
  benchmarks_dir = directory / "tests" / "benchmarks"
110
222
  has_benchmarks = benchmarks_dir.exists() and benchmarks_dir.is_dir()
111
223
 
112
- return cls(
224
+ project_info = cls(
113
225
  name=name,
114
226
  version=version,
115
227
  build_tool=build_tool,
116
228
  has_tests=has_tests,
117
229
  has_benchmarks=has_benchmarks,
118
230
  )
231
+
232
+ # Cache the result
233
+ _project_info_cache.put(directory, project_info)
234
+
235
+ return project_info
119
236
  except Exception as e:
120
237
  logger.warning(f"Failed to parse pyproject.toml: {e}")
121
238
  return None
@@ -123,13 +240,24 @@ class ProjectInfo:
123
240
 
124
241
  @dataclass
125
242
  class MakePythonConfig:
126
- """Configuration for MakePython tool."""
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
+ """
127
255
 
128
256
  default_build_tool: BuildTool = BuildTool.UV
129
257
  auto_detect_tool: bool = True
130
258
  verbose_output: bool = False
131
- max_retries: int = 3
132
- timeout_seconds: int = 300
259
+ max_retries: int = DEFAULT_MAX_RETRIES
260
+ timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS
133
261
 
134
262
  def __post_init__(self) -> None:
135
263
  """Initialize configuration after creation."""
@@ -137,23 +265,41 @@ class MakePythonConfig:
137
265
  atexit.register(self.save)
138
266
 
139
267
  def _load_from_file(self) -> None:
140
- """Load configuration from file if it exists."""
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
+ """
141
273
  if not CONFIG_FILE.exists():
142
274
  return
143
275
 
144
276
  try:
145
- config_data = json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
277
+ config_data = json.loads(CONFIG_FILE.read_text(encoding=DEFAULT_ENCODING))
146
278
  for key, value in config_data.items():
147
279
  if hasattr(self, key):
148
280
  if key == "default_build_tool":
149
- setattr(self, key, BuildTool(value))
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)
150
288
  else:
151
289
  setattr(self, key, value)
152
290
  except (json.JSONDecodeError, TypeError, AttributeError) as e:
153
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}")
154
294
 
155
295
  def save(self) -> None:
156
- """Save current configuration to file."""
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
+ """
157
303
  try:
158
304
  CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
159
305
  config_dict = {
@@ -163,79 +309,177 @@ class MakePythonConfig:
163
309
  "max_retries": self.max_retries,
164
310
  "timeout_seconds": self.timeout_seconds,
165
311
  }
166
- CONFIG_FILE.write_text(json.dumps(config_dict, indent=4), encoding="utf-8")
312
+ CONFIG_FILE.write_text(
313
+ json.dumps(config_dict, indent=4), encoding=DEFAULT_ENCODING
314
+ )
167
315
  logger.debug(f"Configuration saved to {CONFIG_FILE}")
168
316
  except OSError as e:
169
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
170
324
 
171
325
  @cached_property
172
326
  def supported_tools(self) -> list[BuildTool]:
173
- """Get list of available build tools."""
174
- available_tools = []
175
- for tool in BuildTool:
176
- if shutil.which(tool.value):
177
- available_tools.append(tool)
178
- return available_tools
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
+ ]
179
339
 
180
340
 
181
341
  class CommandExecutor(Protocol):
182
- """Protocol for command execution."""
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
+ """
183
347
 
184
348
  def execute(
185
349
  self, command: list[str], cwd: Path
186
- ) -> subprocess.CompletedProcess[str]: ...
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
+ ...
187
365
 
188
366
 
189
367
  class SubprocessExecutor:
190
- """Execute commands using subprocess."""
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
+ """
191
376
 
192
377
  def __init__(self, config: MakePythonConfig):
378
+ """Initialize the executor with configuration.
379
+
380
+ Args:
381
+ config: MakePython configuration object
382
+ """
193
383
  self.config = config
194
384
 
195
385
  def execute(
196
386
  self, command: list[str], cwd: Path
197
387
  ) -> subprocess.CompletedProcess[str]:
198
- """Execute command with proper error handling."""
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
+ """
199
401
  logger.debug(f"Executing command: {' '.join(command)} in {cwd}")
200
402
 
201
403
  try:
202
- result = subprocess.run(
404
+ return _execute_command(
203
405
  command,
204
- cwd=cwd,
205
- capture_output=True,
206
- text=True,
207
- encoding="utf-8",
208
- errors="replace",
209
- timeout=self.config.timeout_seconds,
406
+ cwd,
210
407
  check=True,
408
+ capture=True,
409
+ timeout=self.config.timeout_seconds,
410
+ fallback_encoding=True,
211
411
  )
212
- return result
213
- except subprocess.TimeoutExpired:
214
- logger.error(
215
- f"Command timed out after {self.config.timeout_seconds} seconds"
216
- )
217
- raise
218
412
  except subprocess.CalledProcessError as e:
219
- logger.error(f"Command failed with exit code {e.returncode}")
220
- if e.stdout:
221
- logger.error(f"STDOUT: {e.stdout}")
222
- if e.stderr:
223
- logger.error(f"STDERR: {e.stderr}")
224
- raise
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
225
419
  except Exception as e:
226
- logger.error(f"Unexpected error executing command: {e}")
227
- raise
420
+ raise CommandExecutionError(
421
+ f"Unexpected error executing command: {e}"
422
+ ) from e
228
423
 
229
424
 
230
- def _get_build_command_from_toml(directory: Path) -> str | None:
231
- """Get build command from pyproject.toml."""
232
- logger.debug(f"Parsing pyproject.toml in {directory}")
425
+ class ProjectInfoCache:
426
+ """Simple cache for ProjectInfo objects to improve performance.
233
427
 
234
- project_info = ProjectInfo.from_pyproject(directory)
235
- if not project_info or not project_info.build_tool:
236
- return None
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
237
441
 
238
- return project_info.build_tool.value
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
239
483
 
240
484
 
241
485
  def _get_build_command(directory: Path, config: MakePythonConfig) -> str | None:
@@ -247,6 +491,9 @@ def _get_build_command(directory: Path, config: MakePythonConfig) -> str | None:
247
491
 
248
492
  Returns:
249
493
  Build command string or None if not found
494
+
495
+ Raises:
496
+ BuildToolNotFoundError: If no build tools are available
250
497
  """
251
498
  project_info = ProjectInfo.from_pyproject(directory)
252
499
  if project_info and project_info.build_tool:
@@ -261,8 +508,9 @@ def _get_build_command(directory: Path, config: MakePythonConfig) -> str | None:
261
508
 
262
509
  logger.error(f"No build command found in {directory}")
263
510
  logger.error("Please install uv, poetry, or hatch")
264
- sys.exit(1)
265
- return None
511
+ raise BuildToolNotFoundError(
512
+ f"No build tools found. Please install uv, poetry, or hatch in {directory}"
513
+ )
266
514
 
267
515
 
268
516
  @dataclass
@@ -276,140 +524,331 @@ class Command:
276
524
  description: str = ""
277
525
 
278
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"
538
+
539
+ if len(token) < 10:
540
+ return False, "Token appears too short to be valid"
541
+
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)"
545
+
546
+ return True, "Token format appears valid"
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
+
279
593
  def _clean(root_dir: Path = CURRENT_WORKING_DIR) -> None:
280
- """Clean build artifacts and temporary files."""
281
- clean_commands = [
282
- ["rm", "-rf", "dist"],
283
- ["rm", "-rf", "build"],
284
- ["rm", "-rf", "*.egg-info"],
285
- ]
594
+ """Clean build artifacts and temporary files.
286
595
 
287
- for cmd in clean_commands:
288
- with contextlib.suppress(subprocess.CalledProcessError):
289
- subprocess.run(cmd, cwd=root_dir, capture_output=True, check=True)
596
+ Args:
597
+ root_dir: Root directory to clean
598
+ """
599
+ logger.info(f"Cleaning build artifacts in {root_dir}")
290
600
 
601
+ # Clean common build directories
602
+ targets = ["dist", "build", "*.egg-info"]
603
+ cleaned_count = 0
604
+ failed_count = 0
291
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
+
292
649
  def main() -> None:
293
- """Main entry point for MakePython tool."""
294
- # Initialize configuration
295
- config = MakePythonConfig()
296
-
297
- # Get build command
298
- build_command = _get_build_command(CURRENT_WORKING_DIR, config) or ""
299
- # Create commands with proper structure
300
- commands = [
301
- Command(
302
- name="build",
303
- alias="b",
304
- command_type=CommandType.BUILD,
305
- cmds=[build_command, "build"],
306
- description="Build the project",
307
- ),
308
- Command(
309
- name="bumpversion",
310
- alias="bump",
311
- command_type=CommandType.BUMP_VERSION,
312
- cmds=["bumpversion", "patch", "--tag"],
313
- description="Bump project version",
314
- ),
315
- Command(
316
- name="clean",
317
- alias="c",
318
- command_type=CommandType.CLEAN,
319
- cmds=_clean,
320
- description="Clean build artifacts",
321
- ),
322
- Command(
323
- name="publish",
324
- alias="p",
325
- command_type=CommandType.PUBLISH,
326
- description="Publish package to PyPI",
327
- ),
328
- Command(
329
- name="test",
330
- alias="t",
331
- command_type=CommandType.TEST,
332
- cmds=lambda: os.system("pytest"),
333
- description="Run tests",
334
- ),
335
- Command(
336
- name="test-benchmark",
337
- alias="tb",
338
- command_type=CommandType.TEST,
339
- cmds=lambda: os.system("pytest -m benchmark"),
340
- description="Run benchmark tests",
341
- ),
342
- Command(
343
- name="test-coverage",
344
- alias="tc",
345
- command_type=CommandType.TEST,
346
- cmds=lambda: os.system("pytest --cov=sfi"),
347
- description="Run tests with coverage",
348
- ),
349
- Command(
350
- name="token",
351
- alias="tk",
352
- command_type=CommandType.TOKEN,
353
- cmds=lambda: _set_token(build_command),
354
- description="Set PyPI token",
355
- ),
356
- ]
357
- command_dict = {command.name: command for command in commands}
358
- command_dict.update({command.alias: command for command in commands})
359
- choices = [command.alias for command in commands]
360
- choices.extend([command.name for command in commands])
361
-
362
- # Parse arguments
363
- parser = argparse.ArgumentParser(
364
- prog="makepython",
365
- description="Automated Python project building and management tool",
366
- )
367
- parser.add_argument(
368
- "--debug",
369
- "-d",
370
- action="store_true",
371
- help="Enable debug mode with verbose output",
372
- )
373
- parser.add_argument(
374
- "--verbose", "-v", action="store_true", help="Enable verbose output"
375
- )
376
- parser.add_argument(
377
- "command",
378
- type=str,
379
- choices=choices,
380
- help=f"Command to run. Options: {', '.join(choices)}",
381
- )
382
- args = parser.parse_args()
383
-
384
- # Set logging level
385
- if args.debug:
386
- logger.setLevel(logging.DEBUG)
387
- config.verbose_output = True
388
- elif args.verbose:
389
- logger.setLevel(logging.INFO)
390
- config.verbose_output = True
391
-
392
- logger.debug(f"Using build command: {build_command}")
393
-
394
- # Execute command
395
- command = command_dict.get(args.command)
396
- if command:
397
- if callable(command.cmds):
398
- command.cmds()
399
- elif isinstance(command.cmds, list):
400
- _run_command(command.cmds, CURRENT_WORKING_DIR)
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)
401
834
  else:
402
- logger.debug("No preset commands found for this command type")
403
- else:
404
- 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}")
405
850
  sys.exit(1)
406
851
 
407
- # Special handling for publish command
408
- if args.command in {"publish", "p"}:
409
- if not _check_pypi_token(build_command):
410
- _set_token(build_command)
411
- _run_command([build_command, "publish"], CURRENT_WORKING_DIR)
412
-
413
852
 
414
853
  def _set_token(build_command: str, show_header: bool = True) -> None:
415
854
  """Set PyPI token for the specified build command.
@@ -417,223 +856,335 @@ def _set_token(build_command: str, show_header: bool = True) -> None:
417
856
  Args:
418
857
  build_command: The build tool to configure (uv, poetry, hatch)
419
858
  show_header: Whether to show the header message
859
+
860
+ Raises:
861
+ TokenConfigurationError: If unknown build command is provided or token setting fails
420
862
  """
421
863
  if show_header:
422
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
+ )
423
868
 
424
869
  if build_command.lower() not in [tool.value for tool in BuildTool]:
425
- logger.error(f"Unknown build command: {build_command}")
426
- logger.error(f"Please use one of: {[tool.value for tool in BuildTool]}")
427
- sys.exit(1)
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)
428
873
 
429
874
  token = input("Enter your PyPI token (leave empty to cancel): ").strip()
430
875
  if not token:
431
876
  logger.info("Operation cancelled - no token provided.")
432
877
  return
433
878
 
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)
886
+
434
887
  try:
435
- if build_command == BuildTool.UV.value:
436
- _set_uv_token(token)
437
- elif build_command == BuildTool.POETRY.value:
438
- _set_poetry_token(token)
439
- elif build_command == BuildTool.HATCH.value:
440
- _set_hatch_token(token)
888
+ build_tool = BuildTool(build_command)
889
+ _set_build_tool_token(token, build_tool)
441
890
  logger.info("PyPI token configured successfully!")
891
+ logger.info("You can now use 'makepython publish' to publish your package.")
442
892
  except Exception as e:
443
- logger.error(f"Failed to set token: {e}")
444
- sys.exit(1)
893
+ error_msg = f"Failed to set token: {e}"
894
+ logger.error(error_msg)
895
+ raise TokenConfigurationError(error_msg) from e
445
896
 
446
897
 
447
- def _set_uv_token(token: str) -> None:
448
- """Set PyPI token for uv."""
449
- _write_to_env_file("UV_PUBLISH_TOKEN", token)
898
+ def _save_token_to_file(filepath: Path, content: str, description: str) -> None:
899
+ """Save token content to file with proper error handling.
450
900
 
451
- # Write to `uv.toml`
452
- config_path = Path.home() / ".config" / "uv" / "uv.toml"
453
- config_path.parent.mkdir(parents=True, exist_ok=True)
454
- content = f"""[publish]
455
- token = "{token}"
456
- """
457
- config_path.write_text(content)
458
- logger.info(f"Token saved to {config_path}")
901
+ Args:
902
+ filepath: Path to save the file
903
+ content: Content to write
904
+ description: Description for logging
459
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
460
916
 
461
- def _set_poetry_token(token: str) -> None:
462
- """Set PyPI token for poetry."""
463
- _write_to_env_file("POETRY_PYPI_TOKEN_PYPI", token)
464
- _run_command(["poetry", "config", "pypi-token.pypi", token], CURRENT_WORKING_DIR)
465
- logger.info("Token saved to Poetry configuration.")
466
917
 
918
+ def _set_build_tool_token(token: str, build_tool: BuildTool) -> None:
919
+ """Set PyPI token for the specified build tool.
467
920
 
468
- def _set_hatch_token(token: str) -> None:
469
- """Set PyPI token for hatch."""
470
- pypirc_path = Path.home() / ".pypirc"
471
- pypirc_content = f"""[pypi]
921
+ Args:
922
+ token: PyPI token to set
923
+ build_tool: Build tool to configure
924
+
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]
472
949
  repository = https://upload.pypi.org/legacy/
473
950
  username = __token__
474
951
  password = {token}
475
952
  """
476
- pypirc_path.write_text(pypirc_content)
477
- 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
478
957
 
479
958
 
480
959
  def _check_uv_token() -> bool:
481
- """Check if PyPI token is configured for uv."""
960
+ """Check if UV PyPI token is configured."""
482
961
  # Check environment variables
483
962
  token_env_vars = ["UV_PUBLISH_TOKEN", "PYPI_API_TOKEN"]
484
963
  for var in token_env_vars:
485
964
  if os.getenv(var):
486
- logger.info(f"Found PyPI token in environment variable: {var}")
965
+ logger.debug(f"Found PyPI token in environment variable: {var}")
487
966
  return True
488
967
 
489
968
  # Check config file
490
969
  config_path = Path.home() / ".config" / "uv" / "uv.toml"
491
970
  if config_path.exists():
492
- logger.info(f"Found uv config file: {config_path}")
971
+ logger.debug(f"Found uv config file: {config_path}")
493
972
  return True
494
973
 
495
974
  return False
496
975
 
497
976
 
498
977
  def _check_poetry_token() -> bool:
499
- """Check if PyPI token is configured for poetry."""
978
+ """Check if Poetry PyPI token is configured."""
500
979
  # Check environment variable
501
980
  if os.getenv("POETRY_PYPI_TOKEN_PYPI"):
502
- logger.info("Found PyPI token in POETRY_PYPI_TOKEN_PYPI environment variable")
981
+ logger.debug("Found PyPI token in POETRY_PYPI_TOKEN_PYPI environment variable")
503
982
  return True
504
983
 
505
- # Check poetry config
984
+ # Check Poetry config
506
985
  try:
507
- result = subprocess.run(
986
+ result = _execute_command(
508
987
  ["poetry", "config", "pypi-token.pypi"],
509
- capture_output=True,
510
- text=True,
511
- encoding="utf-8",
512
- errors="replace",
513
- check=True,
988
+ CURRENT_WORKING_DIR,
989
+ check=False,
990
+ capture=True,
514
991
  )
515
992
  if result.stdout.strip() and result.stdout.strip() != "None":
516
- logger.info("Found PyPI token in Poetry configuration")
993
+ logger.debug("Found PyPI token in Poetry configuration")
517
994
  return True
518
- except (subprocess.CalledProcessError, Exception) as e:
519
- logger.error(f"Error checking Poetry token: {e}")
520
-
995
+ except Exception as e:
996
+ logger.debug(f"Error checking Poetry token: {e}")
521
997
  return False
522
998
 
523
999
 
524
1000
  def _check_hatch_token() -> bool:
525
- """Check if PyPI token is configured for hatch."""
1001
+ """Check if Hatch PyPI token is configured."""
526
1002
  pypirc_path = Path.home() / ".pypirc"
527
1003
  if pypirc_path.exists():
528
- logger.info(f"Found .pypirc file: {pypirc_path}")
1004
+ logger.debug(f"Found .pypirc file: {pypirc_path}")
529
1005
  return True
530
1006
  return False
531
1007
 
532
1008
 
533
- def _check_pypi_token(build_command: str) -> bool:
534
- """Check if PyPI token is configured before publishing."""
535
- logger.info("Checking PyPI token configuration...")
1009
+ def _check_build_tool_token(build_tool: BuildTool) -> bool:
1010
+ """Check if PyPI token is configured for the specified build tool.
1011
+
1012
+ Args:
1013
+ build_tool: Build tool to check
536
1014
 
537
- # Map build commands to their respective check functions
538
- checker_map = {
539
- "uv": _check_uv_token,
540
- "poetry": _check_poetry_token,
541
- "hatch": _check_hatch_token,
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,
542
1022
  }
543
1023
 
544
- checker = checker_map.get(build_command.lower())
545
- if not checker:
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...")
1042
+
1043
+ try:
1044
+ build_tool = BuildTool(build_command.lower())
1045
+ return _check_build_tool_token(build_tool)
1046
+ except ValueError:
546
1047
  logger.error(f"Unknown build command for token checking: {build_command}")
547
1048
  return False
548
1049
 
549
- return checker()
550
1050
 
551
-
552
- def _execute_subprocess(
553
- cmd: list[str], directory: Path, encoding: str = "utf-8"
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,
554
1060
  ) -> subprocess.CompletedProcess[str]:
555
- """Execute subprocess with specified encoding."""
556
- return subprocess.run(
557
- cmd,
558
- cwd=directory,
559
- capture_output=True,
560
- text=True,
561
- encoding=encoding,
562
- errors="replace",
563
- check=True,
564
- )
565
-
1061
+ """Execute command with comprehensive error handling.
566
1062
 
567
- def _handle_command_result(result: subprocess.CompletedProcess[str]) -> None:
568
- """Handle and output command results."""
569
- if result.stdout:
570
- print(result.stdout)
571
- if result.stderr:
572
- print(result.stderr, file=sys.stderr)
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)
573
1071
 
1072
+ Returns:
1073
+ CompletedProcess object with command results
574
1074
 
575
- def _handle_called_process_error(e: subprocess.CalledProcessError) -> None:
576
- """Handle CalledProcessError and exit."""
577
- logger.error(f"Command failed with exit code {e.returncode}")
578
- if e.stdout:
579
- logger.error(f"STDOUT: {e.stdout}")
580
- if e.stderr:
581
- logger.error(f"STDERR: {e.stderr}")
582
- sys.exit(e.returncode)
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}")
583
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
+ )
584
1098
 
585
- def _handle_unicode_decode_error(
586
- cmd: list[str], directory: Path, error: UnicodeDecodeError
587
- ) -> None:
588
- """Handle UnicodeDecodeError with fallback encoding."""
589
- logger.error(f"Encoding error while processing command output: {error}")
590
1099
  try:
591
- result = _execute_subprocess(cmd, directory, "gbk" if IS_WINDOWS else "utf-8")
592
- _handle_command_result(result)
593
- except Exception as fallback_error:
594
- logger.error(f"Fallback encoding also failed: {fallback_error}")
595
- sys.exit(1)
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
+
1107
+ except subprocess.CalledProcessError as e:
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
596
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
597
1135
 
598
- def _run_command(cmd: list[str], directory: Path) -> None:
599
- """Run a command in the specified directory with proper error handling.
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.
600
1143
 
601
1144
  Args:
602
1145
  cmd: Command to execute as list of arguments
603
1146
  directory: Directory to run the command in
1147
+ realtime: Whether to stream output in real-time
604
1148
 
605
1149
  Raises:
606
1150
  SystemExit: If command fails to execute
1151
+ ValueError: If inputs are invalid
607
1152
  """
608
- logger.debug(f"Running command: {' '.join(cmd)} in {directory}")
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")
609
1159
 
610
1160
  try:
611
- result = _execute_subprocess(cmd, directory)
612
- _handle_command_result(result)
1161
+ _execute_command(cmd, directory, check=True, realtime=realtime)
613
1162
  except subprocess.CalledProcessError as e:
614
- _handle_called_process_error(e)
615
- except UnicodeDecodeError as e:
616
- _handle_unicode_decode_error(cmd, directory, e)
1163
+ sys.exit(e.returncode)
1164
+ except subprocess.TimeoutExpired:
1165
+ sys.exit(124) # Standard exit code for timeout
617
1166
  except Exception as e:
618
- logger.error(f"Unexpected error executing command: {e}")
1167
+ logger.error(f"Command execution failed: {e}")
619
1168
  sys.exit(1)
620
1169
 
621
1170
 
622
1171
  def _write_to_env_file(key: str, value: str) -> None:
623
1172
  """Write key-value pair to environment file.
624
1173
 
1174
+ Sets environment variables either through system utilities (Windows)
1175
+ or shell configuration files (Unix-like systems).
1176
+
625
1177
  Args:
626
1178
  key: Environment variable name
627
1179
  value: Environment variable value
628
1180
  """
629
1181
  if IS_WINDOWS:
630
1182
  try:
631
- subprocess.run(
1183
+ _execute_command(
632
1184
  ["setx", key, value],
633
- shell=True,
634
- encoding="utf-8",
635
- errors="replace",
1185
+ CURRENT_WORKING_DIR,
636
1186
  check=True,
1187
+ capture=False,
637
1188
  )
638
1189
  logger.info(f"Environment variable {key} set successfully")
639
1190
  except Exception as e:
@@ -644,20 +1195,35 @@ def _write_to_env_file(key: str, value: str) -> None:
644
1195
 
645
1196
 
646
1197
  def _get_shell_config_path() -> Path:
647
- """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
+ """
648
1206
  # Try to detect the shell
649
1207
  shell = os.getenv("SHELL", "")
650
1208
  if "zsh" in shell:
651
1209
  return Path.home() / ".zshrc"
652
1210
  else:
653
1211
  # Default to .bashrc
654
- return Path.home() / ".bashrc"
1212
+ return Path.home() / DEFAULT_SHELL_CONFIG
655
1213
 
656
1214
 
657
1215
  def _write_to_shell_config(content: str) -> None:
658
- """Write content to ~/.bashrc, replacing existing entries if they exist.
1216
+ """Write content to shell configuration file, replacing existing entries.
659
1217
 
660
- Deprecated: Use _write_to_shell_config instead.
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)
1223
+
1224
+ Raises:
1225
+ ValueError: If export statement format is invalid
1226
+ OSError: If file operation fails
661
1227
  """
662
1228
  config_path = _get_shell_config_path()
663
1229
  if not config_path.exists():
@@ -669,17 +1235,16 @@ def _write_to_shell_config(content: str) -> None:
669
1235
  var_name = None
670
1236
  for line in content.strip().split("\n"):
671
1237
  if line.startswith("export ") and "=" in line:
672
- var_name = line.split("=")[0].replace("export ", "").strip()
1238
+ var_name = line.split("=")[0].removeprefix("export ").strip()
673
1239
  break
674
1240
 
675
1241
  if not var_name:
676
- logger.error(
1242
+ raise ValueError(
677
1243
  "Invalid export statement format. Expected: export VARIABLE_NAME=value"
678
1244
  )
679
- return
680
1245
 
681
1246
  # Read existing content
682
- existing_lines = config_path.read_text(encoding="utf-8").split("\n")
1247
+ existing_lines = config_path.read_text(encoding=DEFAULT_ENCODING).split("\n")
683
1248
 
684
1249
  # Find and remove existing export statements for this variable
685
1250
  new_lines = []
@@ -694,17 +1259,13 @@ def _write_to_shell_config(content: str) -> None:
694
1259
  new_lines.append(line)
695
1260
 
696
1261
  if found_existing:
697
- 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...")
698
1263
 
699
1264
  # Add new content
700
1265
  new_lines.append(content.strip())
701
1266
 
702
1267
  # Write back to file
703
- config_path.write_text("\n".join(new_lines), encoding="utf-8")
1268
+ config_path.write_text("\n".join(new_lines), encoding=DEFAULT_ENCODING)
704
1269
 
705
1270
  logger.info(f"Content written to {config_path}")
706
1271
  logger.info(f"Run `source {config_path}` to apply the changes")
707
- logger.info(f"Run `cat {config_path}` to view the content")
708
- logger.info(
709
- f"Run `cat {config_path} | grep 'export'` to view the exported variables"
710
- )