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.
- {pysfi-0.1.12.dist-info → pysfi-0.1.14.dist-info}/METADATA +1 -1
- pysfi-0.1.14.dist-info/RECORD +68 -0
- {pysfi-0.1.12.dist-info → pysfi-0.1.14.dist-info}/entry_points.txt +3 -0
- sfi/__init__.py +19 -2
- sfi/alarmclock/__init__.py +3 -0
- sfi/alarmclock/alarmclock.py +23 -40
- sfi/bumpversion/__init__.py +3 -1
- sfi/bumpversion/bumpversion.py +64 -15
- sfi/cleanbuild/__init__.py +3 -0
- sfi/cleanbuild/cleanbuild.py +5 -1
- sfi/cli.py +25 -4
- sfi/condasetup/__init__.py +1 -0
- sfi/condasetup/condasetup.py +91 -76
- sfi/docdiff/__init__.py +1 -0
- sfi/docdiff/docdiff.py +3 -2
- sfi/docscan/__init__.py +1 -1
- sfi/docscan/docscan.py +78 -23
- sfi/docscan/docscan_gui.py +152 -48
- sfi/filedate/filedate.py +12 -5
- sfi/img2pdf/img2pdf.py +453 -0
- sfi/llmclient/llmclient.py +31 -8
- sfi/llmquantize/llmquantize.py +76 -37
- sfi/llmserver/__init__.py +1 -0
- sfi/llmserver/llmserver.py +63 -13
- sfi/makepython/makepython.py +1145 -201
- sfi/pdfsplit/pdfsplit.py +45 -12
- sfi/pyarchive/__init__.py +1 -0
- sfi/pyarchive/pyarchive.py +908 -278
- sfi/pyembedinstall/pyembedinstall.py +88 -89
- sfi/pylibpack/pylibpack.py +561 -463
- sfi/pyloadergen/pyloadergen.py +372 -218
- sfi/pypack/pypack.py +510 -959
- sfi/pyprojectparse/pyprojectparse.py +337 -40
- sfi/pysourcepack/__init__.py +1 -0
- sfi/pysourcepack/pysourcepack.py +210 -131
- sfi/quizbase/quizbase_gui.py +2 -2
- sfi/taskkill/taskkill.py +168 -59
- sfi/which/which.py +11 -3
- pysfi-0.1.12.dist-info/RECORD +0 -62
- sfi/workflowengine/workflowengine.py +0 -444
- {pysfi-0.1.12.dist-info → pysfi-0.1.14.dist-info}/WHEEL +0 -0
- /sfi/{workflowengine → img2pdf}/__init__.py +0 -0
sfi/makepython/makepython.py
CHANGED
|
@@ -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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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=
|
|
74
|
+
logging.basicConfig(level=logging.INFO, format=LOG_FORMAT, datefmt=LOG_DATE_FORMAT)
|
|
24
75
|
logger = logging.getLogger(__name__)
|
|
25
|
-
|
|
76
|
+
CURRENT_WORKING_DIR: Final[Path] = Path.cwd()
|
|
26
77
|
|
|
27
|
-
|
|
78
|
+
CONFIG_FILE: Final[Path] = Path.home() / CONFIG_DIR_NAME / CONFIG_FILE_NAME
|
|
28
79
|
|
|
29
80
|
|
|
30
|
-
|
|
31
|
-
"""
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
82
|
-
|
|
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
|
-
|
|
93
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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.
|
|
131
|
-
|
|
132
|
-
|
|
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
|
|
147
|
-
|
|
148
|
-
logger.error(
|
|
149
|
-
|
|
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("
|
|
876
|
+
logger.info("Operation cancelled - no token provided.")
|
|
154
877
|
return
|
|
155
878
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
|
182
|
-
"""Set PyPI token for
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
197
|
-
|
|
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
|
|
201
|
-
"""Check if PyPI token is configured
|
|
202
|
-
|
|
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
|
-
|
|
205
|
-
if
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
230
|
-
|
|
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.
|
|
234
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
264
|
-
|
|
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() /
|
|
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
|
|
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
|
-
|
|
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].
|
|
1238
|
+
var_name = line.split("=")[0].removeprefix("export ").strip()
|
|
296
1239
|
break
|
|
297
1240
|
|
|
298
1241
|
if not var_name:
|
|
299
|
-
|
|
300
|
-
|
|
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=
|
|
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(
|
|
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.
|
|
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=
|
|
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")
|