pysfi 0.1.0__py3-none-any.whl → 0.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,107 @@
1
+ Metadata-Version: 2.4
2
+ Name: pysfi
3
+ Version: 0.1.2
4
+ Summary: Single File commands for Interactive python.
5
+ Requires-Python: >=3.8
6
+ Requires-Dist: tomli>=2.4.0; python_version < '3.11'
7
+ Description-Content-Type: text/markdown
8
+
9
+ # pysfi
10
+
11
+ Single File commands for Interactive python.
12
+
13
+ ## Overview
14
+
15
+ pysfi is a Python project that provides single-file command-line utilities, designed to be lightweight and easy-to-use.
16
+
17
+ ## Available Commands
18
+
19
+ - **alarmclk**: Alarm clock functionality
20
+ - **[bumpversion](sfi/bumpversion/README.md)**: Automated version number management tool
21
+ - **embedinstall**: Embed installation utilities
22
+ - **[filedate](sfi/filedate/README.md)**: A file date management tool that normalizes date prefixes in filenames
23
+ - **mkp**: Make Python project utilities
24
+ - **projectparse**: Project parsing and analysis tools
25
+ - **pyloadergen**: Python loader code generation
26
+ - **pypacker**: Python packaging utilities
27
+
28
+ ## Installation
29
+
30
+ ```bash
31
+ # Install using uv (recommended)
32
+ uv add pysfi
33
+
34
+ # Or using pip
35
+ pip install pysfi
36
+ ```
37
+
38
+ ## Development
39
+
40
+ ### Requirements
41
+
42
+ - Python >= 3.8
43
+ - [uv](https://github.com/astral-sh/uv) (recommended) or pip
44
+
45
+ ### Development Dependencies
46
+
47
+ ```bash
48
+ uv pip install -e ".[dev]"
49
+ ```
50
+
51
+ ### Code Standards
52
+
53
+ The project uses Ruff for code linting and formatting:
54
+
55
+ ```bash
56
+ # Check code
57
+ ruff check .
58
+
59
+ # Format code
60
+ ruff format .
61
+ ```
62
+
63
+ ## Project Structure
64
+
65
+ ```bash
66
+ pysfi/
67
+ ├── pyproject.toml # Main project configuration
68
+ ├── README.md
69
+ └── sfi/
70
+ ├── __init__.py
71
+ ├── alarmclock/ # alarmclk command module
72
+ │ ├── alarmclock.py
73
+ │ ├── pyproject.toml
74
+ │ └── __init__.py
75
+ ├── embedinstall/ # embedinstall command module
76
+ │ ├── embedinstall.py
77
+ │ ├── pyproject.toml
78
+ │ └── __init__.py
79
+ ├── filedate/ # filedate command module
80
+ │ ├── filedate.py
81
+ │ ├── pyproject.toml
82
+ │ ├── README.md # Detailed documentation
83
+ │ └── __init__.py
84
+ ├── makepython/ # mkp command module
85
+ │ ├── makepython.py
86
+ │ ├── pyproject.toml
87
+ │ └── __init__.py
88
+ ├── projectparse/ # projectparse command module
89
+ │ ├── projectparse.py
90
+ │ ├── pyproject.toml
91
+ │ └── __init__.py
92
+ ├── pyloadergen/ # pyloadergen command module
93
+ │ ├── pyloadergen.py
94
+ │ ├── pyproject.toml
95
+ │ └── __init__.py
96
+ └── pypacker/ # pypacker command module
97
+ ├── fspacker.py
98
+ └── pyproject.toml
99
+ ```
100
+
101
+ ## License
102
+
103
+ MIT License
104
+
105
+ ## Contributing
106
+
107
+ Issues and Pull Requests are welcome!
@@ -1,6 +1,8 @@
1
- sfi/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
1
+ sfi/__init__.py,sha256=4WjI73hj6c4ccpxA7aVaFsqoetgCa4pYSANxvSTwPpk,74
2
2
  sfi/alarmclock/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
3
  sfi/alarmclock/alarmclock.py,sha256=65G8OyTGpe4oQ2SFerQG1N9PVJ4KxO7WzgsTxpGm4O0,12509
4
+ sfi/bumpversion/__init__.py,sha256=vyfByThaLhG1WtroKYrG2iKJdaNXjXvxGcyAq4FYZ2E,85
5
+ sfi/bumpversion/bumpversion.py,sha256=eneJt0znwQs4D6wm1H2TfECPu3Ut3h-eHzbxbbQiS2I,19024
4
6
  sfi/embedinstall/embedinstall.py,sha256=N5EbTDdX4bE3W0qHGAwAUuepqFr0sbdZuPI3KWrtuUY,14936
5
7
  sfi/filedate/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
8
  sfi/filedate/filedate.py,sha256=DpVp26lumE_Lz_4TgqUEX8IxtK3Y6yHSEFV8qJyegyk,3645
@@ -8,10 +10,8 @@ sfi/makepython/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
10
  sfi/makepython/makepython.py,sha256=9-PPKlxAnaHm9NPhfpn1jcnEYix84rAM77tkHGQeWtA,2244
9
11
  sfi/projectparse/projectparse.py,sha256=Ojg-z4lZEtjEBpJYWyznTgL307N45AxlQKnRkEH0P70,5525
10
12
  sfi/pyloadergen/pyloadergen.py,sha256=yfMOeusAXm89gM02h21zQVoaSt6dkA2khXxfuU6Ognk,34182
11
- sfi/pyloadergen/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
- sfi/pyloadergen/tests/test_pyloadergen.py,sha256=rprkF2Flhg23S875ZWgaJYvwAHdHHh4K4mm5zetYx2I,12817
13
13
  sfi/pypacker/fspacker.py,sha256=3tlS7qiWoH_kOzsp9eSWsQ-SY7-bSTugwfB-HIL69iE,3238
14
- pysfi-0.1.0.dist-info/METADATA,sha256=S3mdC8SfZ4sl3pU9MneCAwu3Qf5c4r07x-bcN4ACJOw,1402
15
- pysfi-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
16
- pysfi-0.1.0.dist-info/entry_points.txt,sha256=aV9Do3TJC0HT-QMorLRmj-KYeCvRYqkdNgE5HeYj98w,320
17
- pysfi-0.1.0.dist-info/RECORD,,
14
+ pysfi-0.1.2.dist-info/METADATA,sha256=9pvadB42FHgTD-QmxdiRSQ2Nf7wa6AuIqJdFNz5Afgs,2755
15
+ pysfi-0.1.2.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
16
+ pysfi-0.1.2.dist-info/entry_points.txt,sha256=6XalkBAzJXRaYaPydlk_Q6Sr6aPOAv8v_5ZBdvg63Lk,367
17
+ pysfi-0.1.2.dist-info/RECORD,,
@@ -1,5 +1,6 @@
1
1
  [console_scripts]
2
2
  alarmclk = sfi.alarmclock.alarmclock:main
3
+ bumpversion = sfi.bumpversion.bumpversion:main
3
4
  embedinstall = sfi.embedinstall.embedinstall:main
4
5
  filedate = sfi.filedate.filedate:main
5
6
  mkp = sfi.makepython.makepython:main
sfi/__init__.py CHANGED
@@ -0,0 +1,3 @@
1
+ """Single File commands for Interactive python."""
2
+
3
+ __version__ = "0.1.2"
@@ -0,0 +1,3 @@
1
+ """Bumpversion - Automated version number management tool."""
2
+
3
+ __version__ = "0.1.2"
@@ -0,0 +1,509 @@
1
+ """Automated version number management tool."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import logging
8
+ import re
9
+ import subprocess
10
+ import sys
11
+ from dataclasses import dataclass
12
+ from enum import Enum
13
+ from pathlib import Path
14
+ from typing import Final
15
+
16
+ try:
17
+ import tomli
18
+ except ImportError:
19
+ import tomllib as tomli # pyright: ignore[reportMissingImports]
20
+
21
+ logging.basicConfig(level=logging.INFO, format="%(message)s")
22
+ logger = logging.getLogger(__name__)
23
+
24
+ VERSION_PATTERN: Final = re.compile(
25
+ r"(?P<major>0|[1-9]\d*)\.(?P<minor>0|[1-9]\d*)\.(?P<patch>0|[1-9]\d*)(?:-(?P<prerelease>(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+(?P<buildmetadata>[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?"
26
+ )
27
+
28
+
29
+ class VersionPart(Enum):
30
+ """Version parts that can be bumped."""
31
+
32
+ MAJOR = "major"
33
+ MINOR = "minor"
34
+ PATCH = "patch"
35
+
36
+
37
+ @dataclass
38
+ class Version:
39
+ """Represents a semantic version number."""
40
+
41
+ major: int
42
+ minor: int
43
+ patch: int
44
+ prerelease: str | None = None
45
+ buildmetadata: str | None = None
46
+
47
+ def __str__(self) -> str:
48
+ """Return version string representation."""
49
+ version = f"{self.major}.{self.minor}.{self.patch}"
50
+ if self.prerelease:
51
+ version += f"-{self.prerelease}"
52
+ if self.buildmetadata:
53
+ version += f"+{self.buildmetadata}"
54
+ return version
55
+
56
+ @classmethod
57
+ def parse(cls, version_string: str) -> Version:
58
+ """Parse version string into Version object."""
59
+ match = VERSION_PATTERN.match(version_string)
60
+ if not match:
61
+ msg = f"Invalid version format: {version_string}"
62
+ raise ValueError(msg)
63
+
64
+ return cls(
65
+ major=int(match.group("major")),
66
+ minor=int(match.group("minor")),
67
+ patch=int(match.group("patch")),
68
+ prerelease=match.group("prerelease"),
69
+ buildmetadata=match.group("buildmetadata"),
70
+ )
71
+
72
+ def bump(self, part: VersionPart, reset_prerelease: bool = True) -> Version:
73
+ """Return a new version with specified part bumped."""
74
+ if part == VersionPart.MAJOR:
75
+ return Version(
76
+ major=self.major + 1,
77
+ minor=0,
78
+ patch=0,
79
+ prerelease=self.prerelease if not reset_prerelease else None,
80
+ buildmetadata=None, # Always reset build metadata
81
+ )
82
+ if part == VersionPart.MINOR:
83
+ return Version(
84
+ major=self.major,
85
+ minor=self.minor + 1,
86
+ patch=0,
87
+ prerelease=self.prerelease if not reset_prerelease else None,
88
+ buildmetadata=None,
89
+ )
90
+ if part == VersionPart.PATCH:
91
+ return Version(
92
+ major=self.major,
93
+ minor=self.minor,
94
+ patch=self.patch + 1,
95
+ prerelease=self.prerelease if not reset_prerelease else None,
96
+ buildmetadata=None,
97
+ )
98
+
99
+ msg = f"Unsupported version part: {part}"
100
+ raise ValueError(msg)
101
+
102
+ def set_prerelease(self, prerelease: str) -> Version:
103
+ """Return a new version with prerelease tag set."""
104
+ return Version(
105
+ major=self.major,
106
+ minor=self.minor,
107
+ patch=self.patch,
108
+ prerelease=prerelease,
109
+ buildmetadata=None,
110
+ )
111
+
112
+
113
+ class FileParser:
114
+ """Parser for different file formats to extract and update version numbers."""
115
+
116
+ @staticmethod
117
+ def _write_with_original_line_ending(file_path: Path, content: str, original_content: str) -> None:
118
+ """Write content to file while preserving original line ending style.
119
+
120
+ Args:
121
+ file_path: Path to the file to write
122
+ content: New content to write
123
+ original_content: Original content to detect line ending style from
124
+ """
125
+ # Detect original line ending style
126
+ newline = "\r\n" if "\r\n" in original_content else "\n"
127
+
128
+ # Write with specified line ending to preserve original style
129
+ with file_path.open("w", encoding="utf-8", newline=newline) as f:
130
+ f.write(content)
131
+
132
+ @staticmethod
133
+ def parse_pyproject(file_path: Path) -> tuple[Version, list[str]]:
134
+ """Parse version from pyproject.toml file."""
135
+ logger.debug(f"Parsing pyproject.toml: {file_path}")
136
+ with file_path.open("rb") as f:
137
+ data = tomli.load(f)
138
+ version_str = data.get("project", {}).get("version")
139
+ if not version_str:
140
+ msg = "Version not found in pyproject.toml"
141
+ raise ValueError(msg)
142
+
143
+ return Version.parse(version_str), ["project.version"]
144
+
145
+ @staticmethod
146
+ def update_pyproject(file_path: Path, new_version: Version) -> None:
147
+ """Update version in pyproject.toml file."""
148
+ logger.debug(f"Updating pyproject.toml: {file_path}")
149
+ # Read file with original line endings preserved
150
+ content = file_path.read_text()
151
+
152
+ # Find and replace version - use word boundary to avoid partial matches
153
+ # Match "version = "..."" as a standalone key, not part of other keys
154
+ pattern = r'(?<![\w-])version\s*=\s*"[^"]+"'
155
+ new_version_str = f'version = "{new_version}"'
156
+ new_content = re.sub(pattern, new_version_str, content)
157
+
158
+ # Write back preserving original line endings
159
+ FileParser._write_with_original_line_ending(file_path, new_content, content)
160
+
161
+ @staticmethod
162
+ def parse_init_py(file_path: Path) -> tuple[Version, list[str]]:
163
+ """Parse version from __init__.py file."""
164
+ logger.debug(f"Parsing __init__.py: {file_path}")
165
+ content = file_path.read_text()
166
+
167
+ match = re.search(r'__version__\s*=\s*["\']([^"\']+)["\']', content)
168
+ if not match:
169
+ msg = "Version not found in __init__.py"
170
+ raise ValueError(msg)
171
+
172
+ return Version.parse(match.group(1)), []
173
+
174
+ @staticmethod
175
+ def update_init_py(file_path: Path, new_version: Version) -> None:
176
+ """Update version in __init__.py file."""
177
+ logger.debug(f"Updating __init__.py: {file_path}")
178
+ content = file_path.read_text()
179
+
180
+ pattern = r'__version__\s*=\s*["\'][^"\']+["\']'
181
+ new_version_str = f'__version__ = "{new_version}"'
182
+ new_content = re.sub(pattern, new_version_str, content)
183
+
184
+ FileParser._write_with_original_line_ending(file_path, new_content, content)
185
+
186
+ @staticmethod
187
+ def parse_setup_py(file_path: Path) -> tuple[Version, list[str]]:
188
+ """Parse version from setup.py file."""
189
+ logger.debug(f"Parsing setup.py: {file_path}")
190
+ content = file_path.read_text()
191
+
192
+ match = re.search(r'version\s*=\s*["\']([^"\']+)["\']', content)
193
+ if not match:
194
+ msg = "Version not found in setup.py"
195
+ raise ValueError(msg)
196
+
197
+ return Version.parse(match.group(1)), []
198
+
199
+ @staticmethod
200
+ def update_setup_py(file_path: Path, new_version: Version) -> None:
201
+ """Update version in setup.py file."""
202
+ logger.debug(f"Updating setup.py: {file_path}")
203
+ content = file_path.read_text()
204
+
205
+ pattern = r'version\s*=\s*["\'][^"\']+["\']'
206
+ new_version_str = f'version = "{new_version}"'
207
+ new_content = re.sub(pattern, new_version_str, content)
208
+
209
+ FileParser._write_with_original_line_ending(file_path, new_content, content)
210
+
211
+ @staticmethod
212
+ def parse_package_json(file_path: Path) -> tuple[Version, list[str]]:
213
+ """Parse version from package.json file."""
214
+ logger.debug(f"Parsing package.json: {file_path}")
215
+ content = file_path.read_text()
216
+
217
+ match = re.search(r'"version"\s*:\s*"([^"]+)"', content)
218
+ if not match:
219
+ msg = "Version not found in package.json"
220
+ raise ValueError(msg)
221
+
222
+ return Version.parse(match.group(1)), []
223
+
224
+ @staticmethod
225
+ def update_package_json(file_path: Path, new_version: Version) -> None:
226
+ """Update version in package.json file."""
227
+ logger.debug(f"Updating package.json: {file_path}")
228
+ content = file_path.read_text()
229
+
230
+ pattern = r'"version"\s*:\s*"[^"]+"'
231
+ new_version_str = f'"version": "{new_version}"'
232
+ new_content = re.sub(pattern, new_version_str, content)
233
+
234
+ FileParser._write_with_original_line_ending(file_path, new_content, content)
235
+
236
+ @staticmethod
237
+ def parse_cargo_toml(file_path: Path) -> tuple[Version, list[str]]:
238
+ """Parse version from Cargo.toml file."""
239
+ logger.debug(f"Parsing Cargo.toml: {file_path}")
240
+ content = file_path.read_text()
241
+
242
+ match = re.search(r'^version\s*=\s*"([^"]+)"', content, re.MULTILINE)
243
+ if not match:
244
+ msg = "Version not found in Cargo.toml"
245
+ raise ValueError(msg)
246
+
247
+ return Version.parse(match.group(1)), []
248
+
249
+ @staticmethod
250
+ def update_cargo_toml(file_path: Path, new_version: Version) -> None:
251
+ """Update version in Cargo.toml file."""
252
+ logger.debug(f"Updating Cargo.toml: {file_path}")
253
+ content = file_path.read_text()
254
+
255
+ pattern = r'^version\s*=\s*"[^"]+"'
256
+ new_version_str = f'version = "{new_version}"'
257
+ new_content = re.sub(pattern, new_version_str, content, flags=re.MULTILINE)
258
+
259
+ FileParser._write_with_original_line_ending(file_path, new_content, content)
260
+
261
+
262
+ class BumpversionManager:
263
+ """Main manager for version bumping operations."""
264
+
265
+ def __init__(self, root_path: Path | None = None) -> None:
266
+ """Initialize BumpversionManager."""
267
+ self.root_path = root_path or Path.cwd()
268
+ self.files: dict[str, Path] = {}
269
+ self.current_version: Version | None = None
270
+ # Load subprojects from projects.json to exclude them from version detection
271
+ self.subproject_paths: set[Path] = set()
272
+ try:
273
+ projects_file = self.root_path / "projects.json"
274
+ if projects_file.exists():
275
+ with projects_file.open("r", encoding="utf-8") as f:
276
+ projects_data = json.load(f)
277
+ self.subproject_paths = {self.root_path / p for p in projects_data.get("subprojects", [])}
278
+ except Exception:
279
+ pass
280
+
281
+ def detect_files(self) -> list[Path]:
282
+ """Detect version files in the project."""
283
+ logger.info("Detecting version files...")
284
+ detected_files: list[Path] = []
285
+
286
+ # Check common version files
287
+ common_files = [
288
+ "pyproject.toml",
289
+ "setup.py",
290
+ "package.json",
291
+ "Cargo.toml",
292
+ ]
293
+
294
+ for file_name in common_files:
295
+ file_path = self.root_path / file_name
296
+ if file_path.exists():
297
+ detected_files.append(file_path)
298
+ logger.info(f"Found: {file_path}")
299
+
300
+ # Check for __init__.py files in all packages (excluding __pycache__ and virtual environments)
301
+ init_files = list(self.root_path.rglob("__init__.py"))
302
+ # Filter out unwanted directories
303
+ excluded_dirs = {"__pycache__", ".venv", "venv", "env", ".git", "dist", "build"}
304
+ init_files = [f for f in init_files if not any(part in excluded_dirs for part in f.parts)]
305
+ # Exclude files inside subprojects defined in projects.json
306
+ init_files = [f for f in init_files if not any(str(f).startswith(str(sp)) for sp in self.subproject_paths)]
307
+ detected_files.extend(init_files)
308
+ for f in init_files:
309
+ logger.info(f"Found: {f}")
310
+
311
+ if not detected_files:
312
+ logger.warning("No version files detected in current directory.")
313
+
314
+ return detected_files
315
+
316
+ def parse_version_from_file(self, file_path: Path) -> Version:
317
+ """Parse version from a file based on its type."""
318
+ if file_path.name == "pyproject.toml":
319
+ return FileParser.parse_pyproject(file_path)[0]
320
+ if file_path.name == "__init__.py":
321
+ return FileParser.parse_init_py(file_path)[0]
322
+ if file_path.name == "setup.py":
323
+ return FileParser.parse_setup_py(file_path)[0]
324
+ if file_path.name == "package.json":
325
+ return FileParser.parse_package_json(file_path)[0]
326
+ if file_path.name == "Cargo.toml":
327
+ return FileParser.parse_cargo_toml(file_path)[0]
328
+
329
+ msg = f"Unsupported file type: {file_path.name}"
330
+ raise ValueError(msg)
331
+
332
+ def update_version_in_file(self, file_path: Path, new_version: Version) -> None:
333
+ """Update version in a file based on its type."""
334
+ if file_path.name == "pyproject.toml":
335
+ FileParser.update_pyproject(file_path, new_version)
336
+ elif file_path.name == "__init__.py":
337
+ FileParser.update_init_py(file_path, new_version)
338
+ elif file_path.name == "setup.py":
339
+ FileParser.update_setup_py(file_path, new_version)
340
+ elif file_path.name == "package.json":
341
+ FileParser.update_package_json(file_path, new_version)
342
+ elif file_path.name == "Cargo.toml":
343
+ FileParser.update_cargo_toml(file_path, new_version)
344
+ else:
345
+ msg = f"Unsupported file type: {file_path.name}"
346
+ raise ValueError(msg)
347
+
348
+ def bump(
349
+ self,
350
+ part: VersionPart,
351
+ files: list[Path] | None = None,
352
+ prerelease: str | None = None,
353
+ commit: bool = False,
354
+ tag: bool = False,
355
+ message: str | None = None,
356
+ ) -> Version:
357
+ """Bump version number in specified files."""
358
+ if not files:
359
+ files = self.detect_files()
360
+
361
+ if not files:
362
+ msg = "No files to update"
363
+ raise ValueError(msg)
364
+
365
+ # Parse current version from first file
366
+ self.current_version = self.parse_version_from_file(files[0])
367
+ logger.info(f"Current version: {self.current_version}")
368
+
369
+ # Calculate new version
370
+ new_version = self.current_version.set_prerelease(prerelease) if prerelease else self.current_version.bump(part)
371
+
372
+ logger.info(f"New version: {new_version}")
373
+
374
+ # Update all files
375
+ for file_path in files:
376
+ try:
377
+ self.update_version_in_file(file_path, new_version)
378
+ logger.info(f"Updated: {file_path}")
379
+ except Exception as e:
380
+ logger.error(f"Failed to update {file_path}: {e}")
381
+
382
+ # Git operations
383
+ if commit or tag:
384
+ self._git_operations(new_version, commit, tag, message, files)
385
+
386
+ return new_version
387
+
388
+ def _git_operations(
389
+ self,
390
+ version: Version,
391
+ commit: bool,
392
+ tag: bool,
393
+ message: str | None,
394
+ files: list[Path],
395
+ ) -> None:
396
+ """Perform git commit and/or tag operations."""
397
+ if not self._is_git_repo():
398
+ logger.warning("Not a git repository, skipping git operations")
399
+ return
400
+
401
+ if commit:
402
+ self._git_commit(version, message, files)
403
+
404
+ if tag:
405
+ self._git_tag(version)
406
+
407
+ def _is_git_repo(self) -> bool:
408
+ """Check if current directory is a git repository."""
409
+ try:
410
+ subprocess.run(
411
+ ["git", "rev-parse", "--git-dir"],
412
+ check=True,
413
+ capture_output=True,
414
+ )
415
+ return True
416
+ except (subprocess.CalledProcessError, FileNotFoundError):
417
+ return False
418
+
419
+ def _git_commit(self, version: Version, message: str | None, files: list[Path]) -> None:
420
+ """Commit version changes to git."""
421
+ commit_message = message or f"chore: bump version to {version}"
422
+
423
+ try:
424
+ subprocess.run(["git", "add"] + [str(f) for f in files], check=True)
425
+ subprocess.run(["git", "commit", "-m", commit_message], check=True)
426
+ logger.info(f"Git commit successful: {commit_message}")
427
+ except subprocess.CalledProcessError as e:
428
+ logger.error(f"Git commit failed: {e}")
429
+
430
+ def _git_tag(self, version: Version) -> None:
431
+ """Create git tag for the new version."""
432
+ tag_name = f"v{version}"
433
+
434
+ try:
435
+ subprocess.run(["git", "tag", "-a", tag_name, "-m", f"Version {version}"], check=True)
436
+ logger.info(f"Git tag created: {tag_name}")
437
+ except subprocess.CalledProcessError as e:
438
+ logger.error(f"Git tag failed: {e}")
439
+
440
+
441
+ def main() -> None:
442
+ """Main entry point for bumpversion command."""
443
+ parser = argparse.ArgumentParser(prog="bumpversion", description="Automated version number management tool")
444
+ parser.add_argument("part", type=str, choices=["major", "minor", "patch"], help="Version part to bump")
445
+ parser.add_argument("--files", "-f", type=str, nargs="*", help="Specific files to update (default: auto-detect)")
446
+ parser.add_argument("--prerelease", "-p", type=str, help="Set prerelease tag (e.g., alpha, beta, rc1)")
447
+ parser.add_argument("--commit", "-c", action="store_true", help="Commit changes to git")
448
+ parser.add_argument("--tag", "-t", action="store_true", help="Create git tag")
449
+ parser.add_argument("--message", "-m", type=str, help="Custom commit message")
450
+ parser.add_argument("--dry-run", "-n", action="store_true", help="Show what would be done without making changes")
451
+ parser.add_argument("--debug", "-d", action="store_true", help="Enable debug mode")
452
+ args = parser.parse_args()
453
+
454
+ if args.debug:
455
+ logger.setLevel(logging.DEBUG)
456
+
457
+ # Parse version part
458
+ try:
459
+ part = VersionPart(args.part.lower())
460
+ except ValueError as e:
461
+ logger.error(f"Invalid version part: {e}")
462
+ sys.exit(1)
463
+
464
+ # Parse files
465
+ files: list[Path] | None = None
466
+ if args.files:
467
+ files = [Path(f) for f in args.files]
468
+ # Validate files exist
469
+ for f in files:
470
+ if not f.exists():
471
+ logger.error(f"File not found: {f}")
472
+ sys.exit(1)
473
+
474
+ # Dry run mode
475
+ if args.dry_run:
476
+ logger.info("Dry run mode - no changes will be made")
477
+ manager = BumpversionManager()
478
+ detected_files = manager.detect_files()
479
+ if not files:
480
+ files = detected_files
481
+ if files:
482
+ current_version = manager.parse_version_from_file(files[0])
483
+ logger.info(f"Current version: {current_version}")
484
+ new_version = current_version.bump(part)
485
+ logger.info(f"New version: {new_version}")
486
+ logger.info(f"Files to update: {files}")
487
+ else:
488
+ logger.info("No files to update")
489
+ return
490
+
491
+ # Perform bump
492
+ try:
493
+ manager = BumpversionManager()
494
+ new_version = manager.bump(
495
+ part=part,
496
+ files=files,
497
+ prerelease=args.prerelease,
498
+ commit=args.commit,
499
+ tag=args.tag,
500
+ message=args.message,
501
+ )
502
+ logger.info(f"Successfully bumped version to: {new_version}")
503
+ except Exception as e:
504
+ logger.error(f"Failed to bump version: {e}")
505
+ sys.exit(1)
506
+
507
+
508
+ if __name__ == "__main__":
509
+ main()
@@ -1,78 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: pysfi
3
- Version: 0.1.0
4
- Summary: Single File commands for Interactive python.
5
- Requires-Python: >=3.8
6
- Description-Content-Type: text/markdown
7
-
8
- # sfi
9
-
10
- Single File commands for Interactive python.
11
-
12
- ## Overview
13
-
14
- sfi is a Python project that provides single-file command-line utilities, designed to be lightweight and easy-to-use.
15
-
16
- ## Available Commands
17
-
18
- ### [filedate](sfi/filedate/README.md)
19
-
20
- A file date management tool that normalizes date prefixes in filenames.
21
-
22
- ## Installation
23
-
24
- ```bash
25
- # Install using uv (recommended)
26
- uv add sfi
27
-
28
- # Or using pip
29
- pip install sfi
30
- ```
31
-
32
- ## Development
33
-
34
- ### Requirements
35
-
36
- - Python >= 3.8
37
- - [uv](https://github.com/astral-sh/uv) (recommended) or pip
38
-
39
- ### Development Dependencies
40
-
41
- ```bash
42
- uv pip install -e ".[dev]"
43
- ```
44
-
45
- ### Code Standards
46
-
47
- The project uses Ruff for code linting and formatting:
48
-
49
- ```bash
50
- # Check code
51
- ruff check .
52
-
53
- # Format code
54
- ruff format .
55
- ```
56
-
57
- ## Project Structure
58
-
59
- ```bash
60
- sfi/
61
- ├── pyproject.toml # Main project configuration
62
- ├── README.md
63
- └── sfi/
64
- ├── __init__.py
65
- └── filedate/ # filedate command module
66
- ├── __init__.py
67
- ├── filedate.py # Main implementation
68
- ├── pyproject.toml
69
- └── README.md # Detailed documentation
70
- ```
71
-
72
- ## License
73
-
74
- MIT License
75
-
76
- ## Contributing
77
-
78
- Issues and Pull Requests are welcome!
File without changes
@@ -1,290 +0,0 @@
1
- """Test suite for pyloadergen module."""
2
-
3
- from __future__ import annotations
4
-
5
- import tempfile
6
- from pathlib import Path
7
- from unittest import mock
8
-
9
- from sfi.pyloadergen import pyloadergen
10
-
11
-
12
- class TestSelectTemplate:
13
- """Test template selection logic."""
14
-
15
- def test_windows_gui_template_non_debug(self):
16
- """Test Windows GUI template selection in non-debug mode."""
17
- with mock.patch("sfi.pyloadergen.pyloadergen.is_windows", True): # noqa: SIM117
18
- with mock.patch("sfi.pyloadergen.pyloadergen.is_macos", False):
19
- result = pyloadergen.select_template("gui", False)
20
- assert result == pyloadergen._WINDOWS_GUI_TEMPLATE
21
-
22
- def test_windows_console_template_non_debug(self):
23
- """Test Windows console template selection in non-debug mode."""
24
- with mock.patch("sfi.pyloadergen.pyloadergen.is_windows", True): # noqa: SIM117
25
- with mock.patch("sfi.pyloadergen.pyloadergen.is_macos", False):
26
- result = pyloadergen.select_template("console", False)
27
- assert result == pyloadergen._WINDOWS_CONSOLE_TEMPLATE
28
-
29
- def test_windows_gui_template_debug(self):
30
- """Test that debug mode forces console template on Windows."""
31
- with mock.patch("sfi.pyloadergen.pyloadergen.is_windows", True): # noqa: SIM117
32
- with mock.patch("sfi.pyloadergen.pyloadergen.is_macos", False):
33
- result = pyloadergen.select_template("gui", True)
34
- assert result == pyloadergen._WINDOWS_CONSOLE_TEMPLATE
35
-
36
- def test_macos_gui_template_non_debug(self):
37
- """Test macOS GUI template selection in non-debug mode."""
38
- with mock.patch("sfi.pyloadergen.pyloadergen.is_windows", False): # noqa: SIM117
39
- with mock.patch("sfi.pyloadergen.pyloadergen.is_macos", True):
40
- result = pyloadergen.select_template("gui", False)
41
- assert result == pyloadergen._MACOS_GUI_TEMPLATE
42
-
43
- def test_macos_console_template_debug(self):
44
- """Test that debug mode forces console template on macOS."""
45
- with mock.patch("sfi.pyloadergen.pyloadergen.is_windows", False): # noqa: SIM117
46
- with mock.patch("sfi.pyloadergen.pyloadergen.is_macos", True):
47
- result = pyloadergen.select_template("gui", True)
48
- assert result == pyloadergen._MACOS_CONSOLE_TEMPLATE
49
-
50
- def test_unix_gui_template_non_debug(self):
51
- """Test Unix GUI template selection in non-debug mode."""
52
- with mock.patch("sfi.pyloadergen.pyloadergen.is_windows", False): # noqa: SIM117
53
- with mock.patch("sfi.pyloadergen.pyloadergen.is_macos", False):
54
- result = pyloadergen.select_template("gui", False)
55
- assert result == pyloadergen._UNIX_GUI_TEMPLATE
56
-
57
- def test_unix_console_template_debug(self):
58
- """Test that debug mode forces console template on Unix."""
59
- with mock.patch("sfi.pyloadergen.pyloadergen.is_windows", False): # noqa: SIM117
60
- with mock.patch("sfi.pyloadergen.pyloadergen.is_macos", False):
61
- result = pyloadergen.select_template("gui", True)
62
- assert result == pyloadergen._UNIX_CONSOLE_TEMPLATE
63
-
64
-
65
- class TestGenerateCSource:
66
- """Test C source code generation."""
67
-
68
- def test_replace_entry_file_placeholder(self):
69
- """Test that ENTRY_FILE placeholder is replaced."""
70
- template = "script: ${ENTRY_FILE}"
71
- result = pyloadergen.generate_c_source(template, "my_script.py", False)
72
- assert "my_script.py" in result
73
- assert "${ENTRY_FILE}" not in result
74
-
75
- def test_replace_debug_mode_placeholder_true(self):
76
- """Test that DEBUG_MODE placeholder is replaced with 1."""
77
- template = "debug: ${DEBUG_MODE}"
78
- result = pyloadergen.generate_c_source(template, "test.py", True)
79
- assert "debug: 1" in result
80
- assert "${DEBUG_MODE}" not in result
81
-
82
- def test_replace_debug_mode_placeholder_false(self):
83
- """Test that DEBUG_MODE placeholder is replaced with 0."""
84
- template = "debug: ${DEBUG_MODE}"
85
- result = pyloadergen.generate_c_source(template, "test.py", False)
86
- assert "debug: 0" in result
87
- assert "${DEBUG_MODE}" not in result
88
-
89
- def test_replace_both_placeholders(self):
90
- """Test that both placeholders are replaced correctly."""
91
- template = "file: ${ENTRY_FILE}, debug: ${DEBUG_MODE}"
92
- result = pyloadergen.generate_c_source(template, "main.py", True)
93
- assert "file: main.py" in result
94
- assert "debug: 1" in result
95
- assert "${ENTRY_FILE}" not in result
96
- assert "${DEBUG_MODE}" not in result
97
-
98
-
99
- class TestGetCompilerArgs:
100
- """Test compiler argument retrieval."""
101
-
102
- def test_gcc_compiler_args(self):
103
- """Test GCC compiler arguments."""
104
- args = pyloadergen.get_compiler_args("gcc")
105
- assert "-std=c99" in args
106
- assert "-Wall" in args
107
- assert "-pedantic" in args
108
- assert "-Werror" in args
109
-
110
- def test_clang_compiler_args(self):
111
- """Test Clang compiler arguments."""
112
- args = pyloadergen.get_compiler_args("clang")
113
- assert "-std=c99" in args
114
- assert "-Wall" in args
115
- assert "-pedantic" in args
116
- assert "-Werror" in args
117
-
118
- def test_cl_compiler_args(self):
119
- """Test MSVC (cl) compiler arguments."""
120
- args = pyloadergen.get_compiler_args("cl")
121
- assert "/std:c99" in args
122
- assert "/Wall" in args
123
- assert "/WX" in args
124
- assert "/Werror" in args
125
-
126
- def test_cl_exe_compiler_args(self):
127
- """Test cl.exe compiler arguments (full path)."""
128
- args = pyloadergen.get_compiler_args("C:\\vc\\bin\\cl.exe")
129
- assert "/std:c99" in args
130
- assert "/Wall" in args
131
- assert "/WX" in args
132
- assert "/Werror" in args
133
-
134
- def test_unknown_compiler_args(self):
135
- """Test unknown compiler returns empty list."""
136
- args = pyloadergen.get_compiler_args("unknown_compiler")
137
- assert args == []
138
-
139
-
140
- class TestFindCompiler:
141
- """Test compiler detection."""
142
-
143
- def test_find_compiler_gcc(self):
144
- """Test finding gcc compiler."""
145
- with mock.patch("shutil.which") as mock_which:
146
- mock_which.return_value = "/usr/bin/gcc"
147
- with mock.patch("sfi.pyloadergen.pyloadergen.is_windows", False):
148
- result = pyloadergen.find_compiler()
149
- assert result == "gcc"
150
-
151
- def test_find_compiler_clang(self):
152
- """Test finding clang compiler."""
153
- # Mock shutil.which to return clang for clang but not for gcc
154
- with mock.patch("shutil.which") as mock_which:
155
-
156
- def which_side_effect(cmd):
157
- # Return gcc None, clang first
158
- if cmd == "gcc":
159
- return None
160
- elif cmd == "clang":
161
- return "/usr/bin/clang"
162
- return None
163
-
164
- mock_which.side_effect = which_side_effect
165
-
166
- with mock.patch("sfi.pyloadergen.pyloadergen.is_windows", False):
167
- result = pyloadergen.find_compiler()
168
- assert result == "clang"
169
-
170
- def test_no_compiler_found(self):
171
- """Test when no compiler is found."""
172
- with mock.patch("shutil.which") as mock_which:
173
- mock_which.return_value = None
174
- result = pyloadergen.find_compiler()
175
- assert result is None
176
-
177
-
178
- class TestCompilerOptions:
179
- """Test compiler options dictionary."""
180
-
181
- def test_compiler_options_has_gcc(self):
182
- """Test GCC options are defined."""
183
- assert "gcc" in pyloadergen._COMPILER_OPTIONS
184
- assert "-std=c99" in pyloadergen._COMPILER_OPTIONS["gcc"]
185
-
186
- def test_compiler_options_has_clang(self):
187
- """Test Clang options are defined."""
188
- assert "clang" in pyloadergen._COMPILER_OPTIONS
189
- assert "-std=c99" in pyloadergen._COMPILER_OPTIONS["clang"]
190
-
191
- def test_compiler_options_has_cl(self):
192
- """Test MSVC options are defined."""
193
- assert "cl" in pyloadergen._COMPILER_OPTIONS
194
- assert "/std:c99" in pyloadergen._COMPILER_OPTIONS["cl"]
195
-
196
-
197
- class TestTemplateContent:
198
- """Test template content validation."""
199
-
200
- def test_windows_gui_template_has_winmain(self):
201
- """Test Windows GUI template has WinMain entry point."""
202
- assert "int APIENTRY WinMain" in pyloadergen._WINDOWS_GUI_TEMPLATE
203
-
204
- def test_windows_console_template_has_main(self):
205
- """Test Windows console template has main entry point."""
206
- assert "int main(" in pyloadergen._WINDOWS_CONSOLE_TEMPLATE
207
-
208
- def test_unix_gui_template_has_main(self):
209
- """Test Unix GUI template has main entry point."""
210
- assert "int main(" in pyloadergen._UNIX_GUI_TEMPLATE
211
-
212
- def test_unix_console_template_has_main(self):
213
- """Test Unix console template has main entry point."""
214
- assert "int main(" in pyloadergen._UNIX_CONSOLE_TEMPLATE
215
-
216
- def test_macos_gui_template_has_main(self):
217
- """Test macOS GUI template has main entry point."""
218
- assert "int main(" in pyloadergen._MACOS_GUI_TEMPLATE
219
-
220
- def test_macos_console_template_has_main(self):
221
- """Test macOS console template has main entry point."""
222
- assert "int main(" in pyloadergen._MACOS_CONSOLE_TEMPLATE
223
-
224
- def test_windows_templates_have_placeholder(self):
225
- """Test Windows templates contain placeholders."""
226
- assert "${ENTRY_FILE}" in pyloadergen._WINDOWS_GUI_TEMPLATE
227
- assert "${DEBUG_MODE}" in pyloadergen._WINDOWS_GUI_TEMPLATE
228
- assert "${ENTRY_FILE}" in pyloadergen._WINDOWS_CONSOLE_TEMPLATE
229
-
230
- def test_unix_templates_have_placeholder(self):
231
- """Test Unix templates contain placeholder."""
232
- assert "${ENTRY_FILE}" in pyloadergen._UNIX_GUI_TEMPLATE
233
- assert "${ENTRY_FILE}" in pyloadergen._UNIX_CONSOLE_TEMPLATE
234
-
235
- def test_macos_templates_have_placeholder(self):
236
- """Test macOS templates contain placeholder."""
237
- assert "${ENTRY_FILE}" in pyloadergen._MACOS_GUI_TEMPLATE
238
- assert "${ENTRY_FILE}" in pyloadergen._MACOS_CONSOLE_TEMPLATE
239
-
240
-
241
- class TestCompileCSource:
242
- """Test C source compilation."""
243
-
244
- def test_compile_creates_executable(self):
245
- """Test compilation creates executable file."""
246
- with tempfile.TemporaryDirectory() as temp_dir:
247
- c_source = Path(temp_dir) / "test.c"
248
- c_source.write_text("#include <stdio.h>\nint main() { return 0; }")
249
- output = Path(temp_dir) / "test.exe"
250
-
251
- with mock.patch("subprocess.run") as mock_run:
252
- mock_run.return_value = mock.Mock(returncode=0, stdout="", stderr="")
253
- result = pyloadergen.compile_c_source(str(c_source), str(output), "gcc")
254
- assert result is True
255
-
256
- def test_compile_failure(self):
257
- """Test compilation failure."""
258
- with tempfile.TemporaryDirectory() as temp_dir:
259
- c_source = Path(temp_dir) / "test.c"
260
- c_source.write_text("invalid C code")
261
- output = Path(temp_dir) / "test.exe"
262
-
263
- with mock.patch("subprocess.run") as mock_run:
264
- mock_run.return_value = mock.Mock(returncode=1, stdout="", stderr="error")
265
- result = pyloadergen.compile_c_source(str(c_source), str(output), "gcc")
266
- assert result is False
267
-
268
- def test_compiler_not_found(self):
269
- """Test when compiler is not found."""
270
- with tempfile.TemporaryDirectory() as temp_dir:
271
- c_source = Path(temp_dir) / "test.c"
272
- c_source.write_text("int main() { return 0; }")
273
- output = Path(temp_dir) / "test.exe"
274
-
275
- with mock.patch("subprocess.run", side_effect=FileNotFoundError):
276
- result = pyloadergen.compile_c_source(str(c_source), str(output), "gcc")
277
- assert result is False
278
-
279
- def test_no_compiler_specified_finds_one(self):
280
- """Test auto-detection of compiler when none specified."""
281
- with tempfile.TemporaryDirectory() as temp_dir:
282
- c_source = Path(temp_dir) / "test.c"
283
- c_source.write_text("int main() { return 0; }")
284
- output = Path(temp_dir) / "test.exe"
285
-
286
- with mock.patch("subprocess.run") as mock_run:
287
- mock_run.return_value = mock.Mock(returncode=0, stdout="", stderr="")
288
- with mock.patch("sfi.pyloadergen.pyloadergen.find_compiler", return_value="gcc"):
289
- result = pyloadergen.compile_c_source(str(c_source), str(output), None)
290
- assert result is True
File without changes