pezin 0.0.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.
pezin/__init__.py ADDED
@@ -0,0 +1,27 @@
1
+ """Automated version bumping and changelog management.
2
+
3
+ Example:
4
+ ```bash
5
+ # Install pre-commit hook
6
+ pre-commit install --hook-type commit-msg
7
+
8
+ # Run manually
9
+ pezin bump minor
10
+ ```
11
+ """
12
+
13
+ from .core.changelog import ChangelogConfig, ChangelogManager
14
+ from .core.commit import BumpType, CommitType, ConventionalCommit
15
+ from .core.version import Version, VersionBumpType
16
+
17
+ __version__ = "0.1.0"
18
+
19
+ __all__ = [
20
+ "Version",
21
+ "VersionBumpType",
22
+ "ConventionalCommit",
23
+ "CommitType",
24
+ "BumpType",
25
+ "ChangelogConfig",
26
+ "ChangelogManager",
27
+ ]
pezin/cli/__init__.py ADDED
@@ -0,0 +1,13 @@
1
+ """CLI package for pezin."""
2
+
3
+ from .commands import bump_version, get_commits_since_last_tag, update_changelog
4
+ from .main import app
5
+ from .utils import find_project_root
6
+
7
+ __all__ = [
8
+ "app",
9
+ "bump_version",
10
+ "update_changelog",
11
+ "get_commits_since_last_tag",
12
+ "find_project_root",
13
+ ]
pezin/cli/commands.py ADDED
@@ -0,0 +1,427 @@
1
+ """Command implementations for pezin CLI."""
2
+
3
+ from datetime import datetime
4
+ from pathlib import Path
5
+ from typing import Any, Dict, List, Optional, Tuple
6
+
7
+ import tomli
8
+ import tomli_w
9
+
10
+ from ..core.changelog import ChangelogConfig, ChangelogManager
11
+ from ..core.commit import ConventionalCommit
12
+ from ..core.version import Version, VersionBumpType, VersionFileConfig, VersionManager
13
+ from ..logging import get_logger
14
+
15
+ logger = get_logger()
16
+
17
+
18
+ def resolve_path(path: Path, base_dir: Optional[Path] = None) -> Path:
19
+ """Resolve a path relative to a base directory."""
20
+ if path.is_absolute():
21
+ return path
22
+ if base_dir is None:
23
+ base_dir = Path.cwd()
24
+ elif base_dir.is_file():
25
+ base_dir = base_dir.parent
26
+ resolved = (base_dir / path).resolve()
27
+ logger.debug(f"Resolved path {path} relative to {base_dir} -> {resolved}")
28
+ return resolved
29
+
30
+
31
+ def read_toml_file(file_path: Path) -> Dict[str, Any]:
32
+ """Read and parse a TOML file."""
33
+ try:
34
+ if not file_path.is_file():
35
+ logger.debug(f"TOML file not found: {file_path}")
36
+ return {}
37
+ content = file_path.read_text()
38
+ logger.debug(f"Reading TOML from {file_path}:\n{content}")
39
+ return tomli.loads(content)
40
+ except Exception as e:
41
+ logger.debug(f"Failed to read TOML file {file_path}: {e}")
42
+ return {}
43
+
44
+
45
+ def read_config(config_file: Path) -> Dict[str, Any]:
46
+ """Read configuration from a TOML file."""
47
+ if config_file.suffix != ".toml":
48
+ return {}
49
+
50
+ config = read_toml_file(config_file)
51
+ base_dir = config_file.parent
52
+
53
+ # Initialize pezin section if needed - check both locations
54
+ pezin_config = {}
55
+ if "pezin" in config:
56
+ pezin_config = config["pezin"]
57
+ elif "tool" in config and "pezin" in config["tool"]:
58
+ pezin_config = config["tool"]["pezin"]
59
+
60
+ # Ensure pezin section exists in the expected location
61
+ config["pezin"] = pezin_config
62
+
63
+ # Make paths absolute
64
+ if config["pezin"]:
65
+ # Handle legacy single version_file configuration
66
+ if "version_file" in config["pezin"]:
67
+ version_path = Path(config["pezin"]["version_file"])
68
+ if not version_path.is_absolute():
69
+ abs_path = resolve_path(version_path, base_dir)
70
+ logger.debug(
71
+ f"Making version_file path absolute: {version_path} -> {abs_path}"
72
+ )
73
+ config["pezin"]["version_file"] = str(abs_path)
74
+
75
+ # Handle new multi-file version_files configuration
76
+ if "version_files" in config["pezin"]:
77
+ version_files = config["pezin"]["version_files"]
78
+ for i, file_config in enumerate(version_files):
79
+ if isinstance(file_config, dict) and "path" in file_config:
80
+ file_path = Path(file_config["path"])
81
+ if not file_path.is_absolute():
82
+ abs_path = resolve_path(file_path, base_dir)
83
+ logger.debug(
84
+ f"Making version_files[{i}] path absolute: {file_path} -> {abs_path}"
85
+ )
86
+ config["pezin"]["version_files"][i]["path"] = str(abs_path)
87
+
88
+ if "changelog_file" in config["pezin"]:
89
+ changelog_path = Path(config["pezin"]["changelog_file"])
90
+ if not changelog_path.is_absolute():
91
+ abs_path = resolve_path(changelog_path, base_dir)
92
+ logger.debug(
93
+ f"Making changelog_file path absolute: {changelog_path} -> {abs_path}"
94
+ )
95
+ config["pezin"]["changelog_file"] = str(abs_path)
96
+
97
+ return config
98
+
99
+
100
+ def read_version_from_toml(file_path: Path) -> Optional[str]:
101
+ """Read version from TOML file sections."""
102
+ logger.debug(f"Reading version from TOML file: {file_path}")
103
+ data = read_toml_file(file_path)
104
+
105
+ # Try project section first
106
+ if "project" in data and "version" in data["project"]:
107
+ return get_version_data(data, "project", "Found version in [project] section: ")
108
+ # Try pezin section next
109
+ if "pezin" in data and "version" in data["pezin"]:
110
+ return get_version_data(data, "pezin", "Found version in [pezin] section: ")
111
+ # Try tool.pezin section (PEP 518)
112
+ if (
113
+ "tool" in data
114
+ and "pezin" in data["tool"]
115
+ and "version" in data["tool"]["pezin"]
116
+ ):
117
+ version = data["tool"]["pezin"]["version"]
118
+ logger.debug(f"Found version in [tool.pezin] section: {version}")
119
+ return version
120
+
121
+ logger.debug("No version found in TOML file")
122
+ return None
123
+
124
+
125
+ def get_version_data(data, project_type, debug_message):
126
+ version = data[project_type]["version"]
127
+ logger.debug(f"{debug_message}{version}")
128
+ return version
129
+
130
+
131
+ def write_toml_version(file_path: Path, new_version: str) -> None:
132
+ """Write version to a TOML file."""
133
+ try:
134
+ data = read_toml_file(file_path)
135
+
136
+ # Update in existing location if found
137
+ if "project" in data and "version" in data["project"]:
138
+ data["project"]["version"] = new_version
139
+ elif "pezin" in data and "version" in data["pezin"]:
140
+ data["pezin"]["version"] = new_version
141
+ elif (
142
+ "tool" in data
143
+ and "pezin" in data["tool"]
144
+ and "version" in data["tool"]["pezin"]
145
+ ):
146
+ data["tool"]["pezin"]["version"] = new_version
147
+ else:
148
+ # Default to project section
149
+ if "project" not in data:
150
+ data["project"] = {}
151
+ data["project"]["version"] = new_version
152
+
153
+ file_path.write_text(tomli_w.dumps(data))
154
+ except Exception as e:
155
+ raise ValueError(f"Failed to write TOML file: {e}") from e
156
+
157
+
158
+ def read_raw_version(file_path: Path) -> Optional[str]:
159
+ """Read version from a raw version file."""
160
+ if not file_path.is_file():
161
+ return None
162
+ content = file_path.read_text().strip()
163
+ if len(content.splitlines()) == 1:
164
+ Version.parse(content) # Validate version string
165
+ return content
166
+
167
+
168
+ def get_version_info(
169
+ config_file: Path, config: Optional[Dict[str, Any]] = None
170
+ ) -> Tuple[str, Path]:
171
+ """Get version information from config or file (legacy function)."""
172
+ if config is None:
173
+ config = read_config(config_file)
174
+
175
+ config_file = config_file.resolve()
176
+ logger.debug(f"Getting version info from {config_file} with config: {config}")
177
+
178
+ # First try reading version from config file if it's TOML
179
+ if config_file.suffix == ".toml":
180
+ if version := read_version_from_toml(config_file):
181
+ return version, config_file
182
+
183
+ # Check for external version file
184
+ if "pezin" in config and "version_file" in config["pezin"]:
185
+ version_file = Path(config["pezin"]["version_file"])
186
+ version_file = resolve_path(version_file, config_file.parent)
187
+ else:
188
+ version_file = config_file
189
+
190
+ logger.debug(f"Checking version file: {version_file}")
191
+
192
+ # Try reading from version file
193
+ if version_file.suffix == ".toml":
194
+ if version := read_version_from_toml(version_file):
195
+ return version, version_file
196
+
197
+ if version := read_raw_version(version_file):
198
+ return version, version_file
199
+
200
+ if not version_file.exists():
201
+ raise FileNotFoundError(f"Version file not found: {version_file}")
202
+
203
+ raise ValueError(f"Version not found in {version_file}")
204
+
205
+
206
+ def get_version_manager(
207
+ config_file: Path, config: Optional[dict] = None
208
+ ) -> VersionManager:
209
+ """Get VersionManager instance from configuration."""
210
+ if config is None:
211
+ config = read_config(config_file)
212
+
213
+ # Check if we have new multi-file configuration
214
+ if config and "pezin" in config and "version_files" in config["pezin"]:
215
+ return VersionManager.from_config(config["pezin"])
216
+
217
+ # Create manager from legacy configuration
218
+ if config and "pezin" in config and "version_file" in config["pezin"]:
219
+ version_file = config["pezin"]["version_file"]
220
+ else:
221
+ version_file = str(config_file)
222
+
223
+ return VersionManager([VersionFileConfig(path=version_file)])
224
+
225
+
226
+ def get_current_version(
227
+ config_file: Path, config: Optional[dict] = None
228
+ ) -> Optional[str]:
229
+ """Get current version using the new VersionManager system."""
230
+ try:
231
+ version_manager = get_version_manager(config_file, config)
232
+ version = version_manager.get_primary_version()
233
+ return str(version) if version else None
234
+ except Exception as e:
235
+ logger.error(f"Failed to get current version: {e}")
236
+ return None
237
+
238
+
239
+ def write_version_to_file(
240
+ file_path: Path, new_version: str, config: Optional[dict] = None
241
+ ) -> None:
242
+ """Write version to a file."""
243
+ file_path = file_path.resolve()
244
+ logger.debug(f"Writing version {new_version} to {file_path}")
245
+
246
+ # Create parent directories if needed
247
+ file_path.parent.mkdir(parents=True, exist_ok=True)
248
+
249
+ # Write version based on file type
250
+ if file_path.suffix == ".toml":
251
+ write_toml_version(file_path, new_version)
252
+ else:
253
+ file_path.write_text(new_version)
254
+
255
+
256
+ def get_changelog_file(config: Dict[str, Any], default_file: Path) -> Path:
257
+ """Get changelog file path from config or default."""
258
+ if "pezin" in config and "changelog_file" in config["pezin"]:
259
+ return Path(config["pezin"]["changelog_file"])
260
+ return default_file
261
+
262
+
263
+ def get_git_repo_url() -> Optional[str]:
264
+ """Get repository URL from git config."""
265
+ import subprocess
266
+
267
+ try:
268
+ result = subprocess.run(
269
+ ["git", "config", "--get", "remote.origin.url"],
270
+ capture_output=True,
271
+ text=True,
272
+ check=True,
273
+ )
274
+ url = result.stdout.strip()
275
+
276
+ # Convert SSH to HTTPS URL if needed
277
+ if url.startswith("git@"):
278
+ url = url.replace(":", "/").replace("git@", "https://")
279
+
280
+ if url.endswith(".git"):
281
+ url = url[:-4]
282
+
283
+ return url
284
+
285
+ except subprocess.CalledProcessError:
286
+ return None
287
+
288
+
289
+ def get_commits_since_last_tag() -> List[ConventionalCommit]:
290
+ """Get commits since the last version tag."""
291
+ import subprocess
292
+
293
+ commits = []
294
+ try:
295
+ get_commits_from_logs(subprocess, commits)
296
+ except subprocess.CalledProcessError:
297
+ logger.debug("Not in a git repository or no commits found")
298
+
299
+ return commits
300
+
301
+
302
+ def get_commits_from_logs(subprocess, commits):
303
+ # Check if in a git repo first
304
+ subprocess.run(["git", "rev-parse", "--git-dir"], capture_output=True, check=True)
305
+
306
+ # Try to get last tag
307
+ result = subprocess.run(
308
+ ["git", "describe", "--tags", "--abbrev=0"],
309
+ capture_output=True,
310
+ text=True,
311
+ check=False,
312
+ )
313
+
314
+ # Build git log command
315
+ log_cmd = ["git", "log", "--pretty=format:%B%n<<>>%n"]
316
+ if result.returncode == 0:
317
+ log_cmd.extend([f"{result.stdout.strip()}..HEAD"])
318
+
319
+ # Get and parse commits
320
+ log_output = subprocess.run(log_cmd, capture_output=True, text=True, check=True)
321
+
322
+ for message in log_output.stdout.split("<<>>\n"):
323
+ if message.strip():
324
+ try:
325
+ commits.append(ConventionalCommit.parse(message.strip()))
326
+ except ValueError:
327
+ continue
328
+
329
+
330
+ def bump_version(
331
+ bump_type: VersionBumpType,
332
+ config_file: Path,
333
+ config: Optional[dict] = None,
334
+ dry_run: bool = False,
335
+ prerelease: Optional[str] = None,
336
+ ) -> Optional[str]:
337
+ """Bump version in config file and all configured version files."""
338
+ try:
339
+ # Read config if not provided
340
+ if config is None and config_file.suffix == ".toml":
341
+ config = read_config(config_file)
342
+
343
+ logger.debug(f"Bumping version with config: {config}")
344
+
345
+ if config and "pezin" in config and "version_files" in config["pezin"]:
346
+ return prepare_new_version(config, bump_type, prerelease, dry_run)
347
+ # Fallback to legacy single file logic
348
+ current, version_file = get_version_info(config_file, config)
349
+ version = Version.parse(current)
350
+ new_version = str(version.bump(bump_type, prerelease))
351
+
352
+ if not dry_run:
353
+ write_version_to_file(version_file, new_version)
354
+ logger.info(f"Version bumped: {current} -> {new_version}")
355
+ else:
356
+ logger.info(f"Dry run - Would bump: {current} -> {new_version}")
357
+
358
+ return new_version
359
+
360
+ except Exception as e:
361
+ logger.error(f"Failed to bump version: {e}")
362
+ return None
363
+
364
+
365
+ def prepare_new_version(config, bump_type, prerelease, dry_run):
366
+ # Use new VersionManager system
367
+ version_manager = VersionManager.from_config(config["pezin"])
368
+
369
+ # Get current version from primary file
370
+ current_version = version_manager.get_primary_version()
371
+ if not current_version:
372
+ raise ValueError("No version found in configured files")
373
+
374
+ # Check version consistency across all files
375
+ if not version_manager.validate_version_consistency():
376
+ logger.warning("Version inconsistency detected across files")
377
+
378
+ # Calculate new version
379
+ new_version = current_version.bump(bump_type, prerelease)
380
+
381
+ if not dry_run:
382
+ updated_files = version_manager.write_versions(new_version)
383
+ logger.info(f"Version bumped: {current_version} -> {new_version}")
384
+ logger.info(f"Updated files: {', '.join(updated_files)}")
385
+ else:
386
+ versions = version_manager.read_versions()
387
+ logger.info(f"Dry run - Would bump: {current_version} -> {new_version}")
388
+ logger.info(f"Files to update: {', '.join(versions.keys())}")
389
+
390
+ return str(new_version)
391
+
392
+
393
+ def update_changelog(
394
+ version: str,
395
+ commits: List[ConventionalCommit],
396
+ changelog_file: Path = Path("CHANGELOG.md"),
397
+ dry_run: bool = False,
398
+ config: Optional[dict] = None,
399
+ ) -> bool:
400
+ """Update changelog with new version."""
401
+ try:
402
+ # Get actual changelog file path
403
+ actual_file = get_changelog_file(config or {}, changelog_file)
404
+ actual_file = resolve_path(actual_file)
405
+
406
+ logger.debug(f"Using changelog file: {actual_file}")
407
+
408
+ # Create changelog file if it doesn't exist
409
+ if not dry_run:
410
+ actual_file.parent.mkdir(parents=True, exist_ok=True)
411
+ if not actual_file.exists():
412
+ actual_file.write_text("# Changelog\n\n## [Unreleased]\n")
413
+
414
+ manager_config = ChangelogConfig(repo_url=get_git_repo_url())
415
+ manager = ChangelogManager(manager_config)
416
+
417
+ if not dry_run:
418
+ manager.update_changelog(actual_file, version, commits, datetime.now())
419
+ logger.info(f"Updated changelog for version {version}")
420
+ else:
421
+ logger.info(f"Dry run - Would update changelog for version {version}")
422
+
423
+ return True
424
+
425
+ except Exception as e:
426
+ logger.error(f"Failed to update changelog: {e}")
427
+ return False