pysfi 0.1.11__py3-none-any.whl → 0.1.13__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.
@@ -1,14 +1,28 @@
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
+
1
7
  from __future__ import annotations
2
8
 
9
+ __version__ = "0.2.0"
10
+ __author__ = "pysfi Developers"
11
+ __email__ = "developers@pysfi.org"
3
12
  import argparse
13
+ import atexit
14
+ import contextlib
15
+ import json
4
16
  import logging
5
17
  import os
6
18
  import shutil
7
19
  import subprocess
8
20
  import sys
9
21
  from dataclasses import dataclass
22
+ from enum import Enum
23
+ from functools import cached_property
10
24
  from pathlib import Path
11
- from typing import Any, Callable
25
+ from typing import Any, Callable, Final, Protocol
12
26
 
13
27
  if sys.version_info >= (3, 11):
14
28
  import tomllib
@@ -16,151 +30,417 @@ else:
16
30
  import tomli as tomllib # type: ignore
17
31
 
18
32
 
19
- is_windows = sys.platform == "win32"
20
- is_linux = sys.platform == "linux"
21
- is_macos = sys.platform == "darwin"
33
+ # Platform detection
34
+ IS_WINDOWS: Final[bool] = sys.platform == "win32"
35
+ IS_LINUX: Final[bool] = sys.platform == "linux"
36
+ IS_MACOS: Final[bool] = sys.platform == "darwin"
22
37
 
23
- logging.basicConfig(level=logging.INFO, format="%(message)s")
38
+ # Logging setup
39
+ logging.basicConfig(
40
+ level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
41
+ )
24
42
  logger = logging.getLogger(__name__)
25
- cwd = Path.cwd()
43
+ CURRENT_WORKING_DIR: Final[Path] = Path.cwd()
26
44
 
27
- _BUILD_COMMANDS = ["uv", "poetry", "hatch"]
45
+ # Supported build tools
46
+ _BUILD_COMMANDS: Final[list[str]] = ["uv", "poetry", "hatch"]
47
+ CONFIG_FILE: Final[Path] = Path.home() / ".pysfi" / "makepython.json"
28
48
 
29
49
 
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 {}
50
+ class BuildTool(Enum):
51
+ """Enumeration of supported build tools."""
52
+
53
+ UV = "uv"
54
+ POETRY = "poetry"
55
+ HATCH = "hatch"
56
+
57
+
58
+ class CommandType(Enum):
59
+ """Enumeration of command types."""
60
+
61
+ BUILD = "build"
62
+ CLEAN = "clean"
63
+ TEST = "test"
64
+ PUBLISH = "publish"
65
+ BUMP_VERSION = "bumpversion"
66
+ TOKEN = "token"
36
67
 
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 {}
68
+
69
+ @dataclass
70
+ class ProjectInfo:
71
+ """Information about the current Python project."""
72
+
73
+ name: str
74
+ version: str
75
+ build_tool: BuildTool | None = None
76
+ has_tests: bool = False
77
+ has_benchmarks: bool = False
78
+
79
+ @classmethod
80
+ def from_pyproject(cls, directory: Path) -> ProjectInfo | None:
81
+ """Create ProjectInfo from pyproject.toml file."""
82
+ pyproject_path = directory / "pyproject.toml"
83
+ if not pyproject_path.exists():
84
+ return None
85
+
86
+ try:
87
+ with pyproject_path.open("rb") as f:
88
+ data = tomllib.load(f)
89
+
90
+ project_data = data.get("project", {})
91
+ name = project_data.get("name", directory.name)
92
+ version = project_data.get("version", "0.1.0")
93
+
94
+ # Detect build tool
95
+ build_tool = None
96
+ if "build-system" in data:
97
+ backend = data["build-system"].get("build-backend", "")
98
+ if backend.startswith("poetry."):
99
+ build_tool = BuildTool.POETRY
100
+ elif backend.startswith("hatchling."):
101
+ build_tool = BuildTool.HATCH
102
+ else:
103
+ build_tool = BuildTool.UV
104
+
105
+ # Check for tests and benchmarks
106
+ tests_dir = directory / "tests"
107
+ has_tests = tests_dir.exists() and tests_dir.is_dir()
108
+
109
+ benchmarks_dir = directory / "tests" / "benchmarks"
110
+ has_benchmarks = benchmarks_dir.exists() and benchmarks_dir.is_dir()
111
+
112
+ return cls(
113
+ name=name,
114
+ version=version,
115
+ build_tool=build_tool,
116
+ has_tests=has_tests,
117
+ has_benchmarks=has_benchmarks,
118
+ )
119
+ except Exception as e:
120
+ logger.warning(f"Failed to parse pyproject.toml: {e}")
121
+ return None
122
+
123
+
124
+ @dataclass
125
+ class MakePythonConfig:
126
+ """Configuration for MakePython tool."""
127
+
128
+ default_build_tool: BuildTool = BuildTool.UV
129
+ auto_detect_tool: bool = True
130
+ verbose_output: bool = False
131
+ max_retries: int = 3
132
+ timeout_seconds: int = 300
133
+
134
+ def __post_init__(self) -> None:
135
+ """Initialize configuration after creation."""
136
+ self._load_from_file()
137
+ atexit.register(self.save)
138
+
139
+ def _load_from_file(self) -> None:
140
+ """Load configuration from file if it exists."""
141
+ if not CONFIG_FILE.exists():
142
+ return
143
+
144
+ try:
145
+ config_data = json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
146
+ for key, value in config_data.items():
147
+ if hasattr(self, key):
148
+ if key == "default_build_tool":
149
+ setattr(self, key, BuildTool(value))
150
+ else:
151
+ setattr(self, key, value)
152
+ except (json.JSONDecodeError, TypeError, AttributeError) as e:
153
+ logger.warning(f"Could not load config from {CONFIG_FILE}: {e}")
154
+
155
+ def save(self) -> None:
156
+ """Save current configuration to file."""
157
+ try:
158
+ CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
159
+ config_dict = {
160
+ "default_build_tool": self.default_build_tool.value,
161
+ "auto_detect_tool": self.auto_detect_tool,
162
+ "verbose_output": self.verbose_output,
163
+ "max_retries": self.max_retries,
164
+ "timeout_seconds": self.timeout_seconds,
165
+ }
166
+ CONFIG_FILE.write_text(json.dumps(config_dict, indent=4), encoding="utf-8")
167
+ logger.debug(f"Configuration saved to {CONFIG_FILE}")
168
+ except OSError as e:
169
+ logger.error(f"Failed to save configuration: {e}")
170
+
171
+ @cached_property
172
+ 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
179
+
180
+
181
+ class CommandExecutor(Protocol):
182
+ """Protocol for command execution."""
183
+
184
+ def execute(
185
+ self, command: list[str], cwd: Path
186
+ ) -> subprocess.CompletedProcess[str]: ...
187
+
188
+
189
+ class SubprocessExecutor:
190
+ """Execute commands using subprocess."""
191
+
192
+ def __init__(self, config: MakePythonConfig):
193
+ self.config = config
194
+
195
+ def execute(
196
+ self, command: list[str], cwd: Path
197
+ ) -> subprocess.CompletedProcess[str]:
198
+ """Execute command with proper error handling."""
199
+ logger.debug(f"Executing command: {' '.join(command)} in {cwd}")
200
+
201
+ try:
202
+ result = subprocess.run(
203
+ command,
204
+ cwd=cwd,
205
+ capture_output=True,
206
+ text=True,
207
+ encoding="utf-8",
208
+ errors="replace",
209
+ timeout=self.config.timeout_seconds,
210
+ check=True,
211
+ )
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
+ 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
225
+ except Exception as e:
226
+ logger.error(f"Unexpected error executing command: {e}")
227
+ raise
43
228
 
44
229
 
45
230
  def _get_build_command_from_toml(directory: Path) -> str | None:
46
231
  """Get build command from pyproject.toml."""
47
232
  logger.debug(f"Parsing pyproject.toml in {directory}")
48
233
 
49
- project_data = parse_pyproject_toml(directory)
50
- if not project_data:
234
+ project_info = ProjectInfo.from_pyproject(directory)
235
+ if not project_info or not project_info.build_tool:
51
236
  return None
52
237
 
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
238
+ return project_info.build_tool.value
239
+
67
240
 
241
+ def _get_build_command(directory: Path, config: MakePythonConfig) -> str | None:
242
+ """Get build command from directory using configuration.
68
243
 
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)
244
+ Args:
245
+ directory: Directory to search for project configuration
246
+ config: MakePython configuration object
247
+
248
+ Returns:
249
+ Build command string or None if not found
250
+ """
251
+ project_info = ProjectInfo.from_pyproject(directory)
252
+ if project_info and project_info.build_tool:
253
+ logger.debug(f"Detected build tool: {project_info.build_tool.value}")
254
+ return project_info.build_tool.value
255
+
256
+ # Fallback to available tools
257
+ if config.supported_tools:
258
+ selected_tool = config.supported_tools[0]
259
+ logger.debug(f"Using available build tool: {selected_tool.value}")
260
+ return selected_tool.value
75
261
 
76
- for command in _BUILD_COMMANDS:
77
- if shutil.which(command):
78
- logger.debug(f"Found build command: {command}")
79
- return command
80
262
  logger.error(f"No build command found in {directory}")
263
+ logger.error("Please install uv, poetry, or hatch")
81
264
  sys.exit(1)
82
265
  return None
83
266
 
84
267
 
85
268
  @dataclass
86
269
  class Command:
270
+ """Represents a command that can be executed."""
271
+
87
272
  name: str
88
273
  alias: str
274
+ command_type: CommandType
89
275
  cmds: list[str] | Callable[..., Any] | None = None
276
+ description: str = ""
90
277
 
91
278
 
92
- def _clean(root_dir: Path = cwd):
93
- _run_command(["rm", "-rf", "dist", "build", "*.egg-info"], root_dir)
279
+ 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
+ ]
286
+
287
+ for cmd in clean_commands:
288
+ with contextlib.suppress(subprocess.CalledProcessError):
289
+ subprocess.run(cmd, cwd=root_dir, capture_output=True, check=True)
94
290
 
95
291
 
96
292
  def main() -> None:
293
+ """Main entry point for MakePython tool."""
294
+ # Initialize configuration
295
+ config = MakePythonConfig()
296
+
97
297
  # Get build command
98
- build_command = _get_build_command(cwd) or ""
298
+ build_command = _get_build_command(CURRENT_WORKING_DIR, config) or ""
299
+ # Create commands with proper structure
99
300
  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)),
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
+ ),
108
356
  ]
109
357
  command_dict = {command.name: command for command in commands}
110
358
  command_dict.update({command.alias: command for command in commands})
111
359
  choices = [command.alias for command in commands]
112
360
  choices.extend([command.name for command in commands])
113
361
 
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}")
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
+ )
118
382
  args = parser.parse_args()
383
+
384
+ # Set logging level
119
385
  if args.debug:
120
386
  logger.setLevel(logging.DEBUG)
387
+ config.verbose_output = True
388
+ elif args.verbose:
389
+ logger.setLevel(logging.INFO)
390
+ config.verbose_output = True
121
391
 
122
392
  logger.debug(f"Using build command: {build_command}")
393
+
394
+ # Execute command
123
395
  command = command_dict.get(args.command)
124
396
  if command:
125
397
  if callable(command.cmds):
126
398
  command.cmds()
127
399
  elif isinstance(command.cmds, list):
128
- _run_command(command.cmds, cwd)
400
+ _run_command(command.cmds, CURRENT_WORKING_DIR)
129
401
  else:
130
- logger.debug("No preset commands found")
402
+ logger.debug("No preset commands found for this command type")
131
403
  else:
132
404
  logger.error(f"Unknown command: {args.command}")
133
405
  sys.exit(1)
134
406
 
407
+ # Special handling for publish command
135
408
  if args.command in {"publish", "p"}:
136
409
  if not _check_pypi_token(build_command):
137
410
  _set_token(build_command)
138
- _run_command([build_command, "publish"], cwd)
411
+ _run_command([build_command, "publish"], CURRENT_WORKING_DIR)
139
412
 
140
413
 
141
414
  def _set_token(build_command: str, show_header: bool = True) -> None:
142
- """Set PyPI token for the specified build command."""
415
+ """Set PyPI token for the specified build command.
416
+
417
+ Args:
418
+ build_command: The build tool to configure (uv, poetry, hatch)
419
+ show_header: Whether to show the header message
420
+ """
143
421
  if show_header:
144
422
  logger.info(f"Setting PyPI token for {build_command}...")
145
423
 
146
- if build_command.lower() not in _BUILD_COMMANDS:
424
+ if build_command.lower() not in [tool.value for tool in BuildTool]:
147
425
  logger.error(f"Unknown build command: {build_command}")
148
- logger.error(f"Please use `{'/'.join(_BUILD_COMMANDS)}`")
426
+ logger.error(f"Please use one of: {[tool.value for tool in BuildTool]}")
149
427
  sys.exit(1)
150
428
 
151
429
  token = input("Enter your PyPI token (leave empty to cancel): ").strip()
152
430
  if not token:
153
- logger.info("Invalid token, cancelled.")
431
+ logger.info("Operation cancelled - no token provided.")
154
432
  return
155
433
 
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}")
434
+ 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)
441
+ logger.info("PyPI token configured successfully!")
442
+ except Exception as e:
443
+ logger.error(f"Failed to set token: {e}")
164
444
  sys.exit(1)
165
445
 
166
446
 
@@ -181,7 +461,7 @@ token = "{token}"
181
461
  def _set_poetry_token(token: str) -> None:
182
462
  """Set PyPI token for poetry."""
183
463
  _write_to_env_file("POETRY_PYPI_TOKEN_PYPI", token)
184
- _run_command(["poetry", "config", "pypi-token.pypi", token], cwd)
464
+ _run_command(["poetry", "config", "pypi-token.pypi", token], CURRENT_WORKING_DIR)
185
465
  logger.info("Token saved to Poetry configuration.")
186
466
 
187
467
 
@@ -197,71 +477,168 @@ password = {token}
197
477
  logger.info(f"Token saved to {pypirc_path}")
198
478
 
199
479
 
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...")
480
+ def _check_uv_token() -> bool:
481
+ """Check if PyPI token is configured for uv."""
482
+ # Check environment variables
483
+ token_env_vars = ["UV_PUBLISH_TOKEN", "PYPI_API_TOKEN"]
484
+ for var in token_env_vars:
485
+ if os.getenv(var):
486
+ logger.info(f"Found PyPI token in environment variable: {var}")
487
+ return True
488
+
489
+ # Check config file
490
+ config_path = Path.home() / ".config" / "uv" / "uv.toml"
491
+ if config_path.exists():
492
+ logger.info(f"Found uv config file: {config_path}")
493
+ return True
494
+
495
+ return False
496
+
203
497
 
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
498
+ def _check_poetry_token() -> bool:
499
+ """Check if PyPI token is configured for poetry."""
500
+ # Check environment variable
501
+ if os.getenv("POETRY_PYPI_TOKEN_PYPI"):
502
+ logger.info("Found PyPI token in POETRY_PYPI_TOKEN_PYPI environment variable")
503
+ return True
504
+
505
+ # Check poetry config
506
+ try:
227
507
  result = subprocess.run(
228
508
  ["poetry", "config", "pypi-token.pypi"],
229
509
  capture_output=True,
230
510
  text=True,
511
+ encoding="utf-8",
512
+ errors="replace",
513
+ check=True,
231
514
  )
232
515
  if result.stdout.strip() and result.stdout.strip() != "None":
233
516
  logger.info("Found PyPI token in Poetry configuration")
234
- token_found = True
517
+ return True
518
+ except (subprocess.CalledProcessError, Exception) as e:
519
+ logger.error(f"Error checking Poetry token: {e}")
235
520
 
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
521
+ return False
242
522
 
243
- return token_found
244
523
 
524
+ def _check_hatch_token() -> bool:
525
+ """Check if PyPI token is configured for hatch."""
526
+ pypirc_path = Path.home() / ".pypirc"
527
+ if pypirc_path.exists():
528
+ logger.info(f"Found .pypirc file: {pypirc_path}")
529
+ return True
530
+ return False
245
531
 
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
- try:
250
- result = subprocess.run(cmd, cwd=directory, capture_output=True, text=True)
251
- except subprocess.CalledProcessError as e:
252
- logger.error(f"Command failed with exit code {e.returncode}")
253
- sys.exit(e.returncode)
254
532
 
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...")
536
+
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,
542
+ }
543
+
544
+ checker = checker_map.get(build_command.lower())
545
+ if not checker:
546
+ logger.error(f"Unknown build command for token checking: {build_command}")
547
+ return False
548
+
549
+ return checker()
550
+
551
+
552
+ def _execute_subprocess(
553
+ cmd: list[str], directory: Path, encoding: str = "utf-8"
554
+ ) -> 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
+
566
+
567
+ def _handle_command_result(result: subprocess.CompletedProcess[str]) -> None:
568
+ """Handle and output command results."""
255
569
  if result.stdout:
256
570
  print(result.stdout)
257
571
  if result.stderr:
258
572
  print(result.stderr, file=sys.stderr)
259
573
 
260
574
 
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)
583
+
584
+
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
+ 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)
596
+
597
+
598
+ def _run_command(cmd: list[str], directory: Path) -> None:
599
+ """Run a command in the specified directory with proper error handling.
600
+
601
+ Args:
602
+ cmd: Command to execute as list of arguments
603
+ directory: Directory to run the command in
604
+
605
+ Raises:
606
+ SystemExit: If command fails to execute
607
+ """
608
+ logger.debug(f"Running command: {' '.join(cmd)} in {directory}")
609
+
610
+ try:
611
+ result = _execute_subprocess(cmd, directory)
612
+ _handle_command_result(result)
613
+ except subprocess.CalledProcessError as e:
614
+ _handle_called_process_error(e)
615
+ except UnicodeDecodeError as e:
616
+ _handle_unicode_decode_error(cmd, directory, e)
617
+ except Exception as e:
618
+ logger.error(f"Unexpected error executing command: {e}")
619
+ sys.exit(1)
620
+
621
+
261
622
  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)
623
+ """Write key-value pair to environment file.
624
+
625
+ Args:
626
+ key: Environment variable name
627
+ value: Environment variable value
628
+ """
629
+ if IS_WINDOWS:
630
+ try:
631
+ subprocess.run(
632
+ ["setx", key, value],
633
+ shell=True,
634
+ encoding="utf-8",
635
+ errors="replace",
636
+ check=True,
637
+ )
638
+ logger.info(f"Environment variable {key} set successfully")
639
+ except Exception as e:
640
+ logger.warning(f"Failed to set environment variable {key}: {e}")
641
+ logger.info("You may need to restart your shell for changes to take effect")
265
642
  else:
266
643
  _write_to_shell_config(f"export {key}='{value}'")
267
644
 
@@ -296,7 +673,9 @@ def _write_to_shell_config(content: str) -> None:
296
673
  break
297
674
 
298
675
  if not var_name:
299
- logger.error("Invalid export statement format. Expected: export VARIABLE_NAME=value")
676
+ logger.error(
677
+ "Invalid export statement format. Expected: export VARIABLE_NAME=value"
678
+ )
300
679
  return
301
680
 
302
681
  # Read existing content
@@ -307,7 +686,9 @@ def _write_to_shell_config(content: str) -> None:
307
686
  found_existing = False
308
687
  for line in existing_lines:
309
688
  # 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} ="):
689
+ if line.strip().startswith(f"export {var_name}=") or line.strip().startswith(
690
+ f"export {var_name} ="
691
+ ):
311
692
  found_existing = True
312
693
  continue
313
694
  new_lines.append(line)
@@ -324,4 +705,6 @@ def _write_to_shell_config(content: str) -> None:
324
705
  logger.info(f"Content written to {config_path}")
325
706
  logger.info(f"Run `source {config_path}` to apply the changes")
326
707
  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")
708
+ logger.info(
709
+ f"Run `cat {config_path} | grep 'export'` to view the exported variables"
710
+ )