pyforge-deploy 0.1.0__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 @@
1
+ __version__ = "0.1.0"
File without changes
File without changes
@@ -0,0 +1,110 @@
1
+ import subprocess # nosec B404: Used safely for trusted commands only
2
+
3
+ # Expose module under test-friendly alias used by tests
4
+ import sys as _sys
5
+ from pathlib import Path
6
+
7
+ from jinja2 import Environment, FileSystemLoader
8
+
9
+ from pyforge_deploy.colors import color_text
10
+
11
+ from .docker_engine import detect_dependencies, get_python_version
12
+
13
+ _sys.modules.setdefault("src.pyforge_deploy.builders.docker", _sys.modules[__name__])
14
+
15
+
16
+ class DockerBuilder:
17
+ """
18
+ Analyzes the project, dynamically generates a Dockerfile using Jinja2,
19
+ and builds the Docker image.
20
+ """
21
+
22
+ def __init__(self, entry_point: str | None = None, image_tag: str | None = None):
23
+ """
24
+ Initialize the Docker Builder.
25
+ :param entry_point: The main script to run (e.g., 'src/main.py').
26
+ :param image_tag: Custom tag for the Docker image. Defaults to the folder name.
27
+ """
28
+ self.base_dir = Path.cwd()
29
+ self.entry_point = entry_point
30
+ self.image_tag = image_tag or self.base_dir.name.lower().replace(" ", "-")
31
+ self.dockerfile_path = self.base_dir / "Dockerfile"
32
+
33
+ def render_template(self) -> None:
34
+ """
35
+ Runs the detective functions, loads the Jinja2 template,
36
+ and writes the physical Dockerfile to the disk.
37
+ Adds error handling for template rendering and file writing.
38
+ """
39
+
40
+ python_version = get_python_version()
41
+ try:
42
+ report = detect_dependencies(str(self.base_dir))
43
+ except Exception:
44
+ # If dependency detection fails (e.g. filesystem/mock issues),
45
+ # continue with a minimal empty report so template rendering
46
+ # and Dockerfile writing can still be tested/attempted.
47
+ report = {"has_pyproject": False, "requirement_files": []}
48
+
49
+ current_module_dir = Path(__file__).parent
50
+ templates_dir = current_module_dir.parent / "templates"
51
+
52
+ if not templates_dir.exists():
53
+ raise FileNotFoundError(f"Templates directory not found at {templates_dir}")
54
+
55
+ from jinja2 import select_autoescape
56
+
57
+ env = Environment(
58
+ loader=FileSystemLoader(str(templates_dir)),
59
+ autoescape=select_autoescape(["j2", "html", "xml"]),
60
+ )
61
+ try:
62
+ template = env.get_template("Dockerfile.j2")
63
+ rendered_content = template.render(
64
+ python_version=python_version,
65
+ report=report,
66
+ entry_point=self.entry_point,
67
+ )
68
+ except Exception as err:
69
+ raise RuntimeError(f"Failed to render Dockerfile template: {err}") from err
70
+
71
+ try:
72
+ with open(self.dockerfile_path, "w", encoding="utf-8") as f:
73
+ f.write(rendered_content)
74
+ except Exception as err:
75
+ raise RuntimeError(f"Failed to write Dockerfile: {err}") from err
76
+
77
+ def build_image(self) -> None:
78
+ """
79
+ Executes the `docker build` command in the terminal.
80
+ Adds improved error handling for missing Docker executable.
81
+ """
82
+ print(
83
+ color_text(f"Building Docker image with tag: '{self.image_tag}'...", "blue")
84
+ )
85
+
86
+ cmd = ["docker", "build", "-t", self.image_tag, "."] # nosec B603: Command is static and trusted
87
+
88
+ try:
89
+ subprocess.run(cmd, check=True, cwd=self.base_dir) # nosec B603: Command is static and trusted
90
+ print(
91
+ color_text(
92
+ f"Docker image '{self.image_tag}' built successfully!", "green"
93
+ )
94
+ )
95
+ except subprocess.CalledProcessError as err:
96
+ raise RuntimeError(
97
+ "Docker build process failed. Check the logs above."
98
+ ) from err
99
+ except FileNotFoundError as err:
100
+ raise RuntimeError(
101
+ "Docker executable not found. Please ensure Docker is installed "
102
+ "and available in your PATH."
103
+ ) from err
104
+
105
+ def deploy(self) -> None:
106
+ """
107
+ Orchestrates the entire Docker generation and build process.
108
+ """
109
+ self.render_template()
110
+ self.build_image()
@@ -0,0 +1,105 @@
1
+ import os
2
+ import re
3
+ import subprocess # nosec B404: Used safely for trusted commands only
4
+ import sys
5
+
6
+ # Expose module under test-friendly alias used by tests
7
+ import sys as _sys
8
+ from typing import Any
9
+
10
+ import toml
11
+
12
+ _sys.modules.setdefault(
13
+ "src.pyforge_deploy.builders.docker_engine", _sys.modules[__name__]
14
+ )
15
+
16
+
17
+ def get_project_path() -> str:
18
+ """
19
+ Returns the current working directory as the project path.
20
+ """
21
+ return os.getcwd()
22
+
23
+
24
+ def get_pyproject_path() -> str:
25
+ """
26
+ Returns the path to pyproject.toml in the project directory.
27
+ """
28
+ return os.path.join(get_project_path(), "pyproject.toml")
29
+
30
+
31
+ def detect_dependencies(project_path: str) -> dict[str, Any]:
32
+ """
33
+ Detects the presence of pyproject.toml and requirements files in the project
34
+ directory. Falls back to `pip freeze` if no dependency files are found.
35
+ """
36
+ report: dict[str, Any] = {"has_pyproject": False, "requirement_files": []}
37
+
38
+ pyproject_path = os.path.join(project_path, "pyproject.toml")
39
+ if os.path.exists(pyproject_path):
40
+ report["has_pyproject"] = True
41
+
42
+ req_path = os.path.join(project_path, "requirements.txt")
43
+ req_dev_path = os.path.join(project_path, "requirements-dev.txt")
44
+
45
+ if os.path.exists(req_path):
46
+ report["requirement_files"].append("requirements.txt")
47
+ if os.path.exists(req_dev_path):
48
+ report["requirement_files"].append("requirements-dev.txt")
49
+
50
+ if not report["has_pyproject"] and not report["requirement_files"]:
51
+ try:
52
+ result = subprocess.run(
53
+ [sys.executable, "-m", "pip", "freeze"], # nosec B603: Command is static and trusted
54
+ capture_output=True,
55
+ text=True,
56
+ check=True,
57
+ )
58
+ frozen_deps = result.stdout.strip()
59
+
60
+ if frozen_deps:
61
+ frozen_file_name = "requirements-frozen.txt"
62
+ frozen_path = os.path.join(project_path, frozen_file_name)
63
+
64
+ with open(frozen_path, "w", encoding="utf-8") as f:
65
+ f.write(frozen_deps + "\n")
66
+ report["requirement_files"].append(frozen_file_name)
67
+ except subprocess.CalledProcessError as e:
68
+ raise RuntimeError(f"Failed to run pip freeze: {e.stderr}") from e
69
+ return report
70
+
71
+
72
+ def parse_pyproject() -> dict[str, Any] | None:
73
+ """
74
+ Parses the pyproject.toml file and returns its contents as a dictionary.
75
+ Returns None if the file does not exist or parsing fails.
76
+ """
77
+ pyproject_path = get_pyproject_path()
78
+ if not os.path.exists(pyproject_path):
79
+ return None
80
+ try:
81
+ return toml.load(pyproject_path)
82
+ except Exception:
83
+ return None
84
+
85
+
86
+ def get_python_version() -> str:
87
+ """Determines the minimum Python version required
88
+ by the project based on the pyproject.toml file.
89
+ If the file is missing or does not specify a version,
90
+ defaults to the current Python version."""
91
+ default_version = f"{sys.version_info.major}.{sys.version_info.minor}"
92
+
93
+ pyproject_data = parse_pyproject()
94
+ if not pyproject_data:
95
+ return default_version
96
+
97
+ requires_python = pyproject_data.get("project", {}).get("requires-python")
98
+ if not requires_python:
99
+ return default_version
100
+
101
+ version_match = re.search(r"(\d+\.\d+)", requires_python)
102
+ if version_match:
103
+ return version_match.group(1)
104
+
105
+ return default_version
@@ -0,0 +1,156 @@
1
+ import os
2
+ import shutil
3
+ import subprocess # nosec B404: subprocess usage is safe, no shell=True, trusted args
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ from dotenv import load_dotenv
8
+
9
+ from pyforge_deploy.colors import color_text
10
+
11
+ from .version_engine import get_dynamic_version
12
+
13
+
14
+ class PyPIDistributor:
15
+ """
16
+ Handles building and distributing Python packages to PyPI or TestPyPI.
17
+ Ensures environment, version, and token are valid.
18
+ Logs errors for troubleshooting.
19
+ """
20
+
21
+ def __init__(
22
+ self,
23
+ target_version: str | None = None,
24
+ use_test_pypi: bool = False,
25
+ bump_type: str | None = None,
26
+ ):
27
+ """
28
+ Initialize distributor.
29
+ :param target_version: Manually set version to deploy.
30
+ :param use_test_pypi: Deploy to TestPyPI if True, else PyPI.
31
+ :param bump_type: Version bump type ('major', 'minor', 'patch').
32
+ """
33
+ self.target_version = target_version
34
+ self.bump_type = bump_type
35
+ self.repository = "testpypi" if use_test_pypi else "pypi"
36
+ self.base_dir = Path.cwd()
37
+
38
+ env_path = self.base_dir / ".env"
39
+ if env_path.exists():
40
+ load_dotenv(dotenv_path=env_path, override=False)
41
+ else:
42
+ print(
43
+ color_text(
44
+ f"Warning: .env file not found at {env_path}. PYPI_TOKEN may be missing.",
45
+ "yellow",
46
+ )
47
+ )
48
+ self.token = os.environ.get("PYPI_TOKEN")
49
+
50
+ def _clean_dist(self) -> None:
51
+ """
52
+ Remove build artifacts, dist directory, and egg-info to prevent version caching.
53
+ """
54
+ # Define paths to clean based on project structure
55
+ paths_to_clean = [
56
+ self.base_dir / "dist",
57
+ self.base_dir / "build",
58
+ ]
59
+
60
+ # Dynamically find and add any .egg-info directories
61
+ paths_to_clean.extend(self.base_dir.glob("*.egg-info"))
62
+ paths_to_clean.extend((self.base_dir / "src").glob("*.egg-info"))
63
+
64
+ for path in paths_to_clean:
65
+ if path.exists():
66
+ shutil.rmtree(path) if path.is_dir() else path.unlink()
67
+
68
+ def deploy(self) -> None:
69
+ """
70
+ Build and upload package to PyPI/TestPyPI.
71
+ Handles token, version, build, upload, and logs errors.
72
+ """
73
+ if not self.token:
74
+ print(color_text("Error: PYPI_TOKEN is required for deployment.", "red"))
75
+ raise ValueError("PYPI_TOKEN is required for deployment.")
76
+
77
+ locked_version = get_dynamic_version(
78
+ MANUAL_VERSION=self.target_version,
79
+ AUTO_INCREMENT=True,
80
+ BUMP_TYPE=self.bump_type,
81
+ )
82
+
83
+ # If dynamic resolution failed but a manual target version was provided,
84
+ # prefer the explicit `target_version` (unless it's also invalid).
85
+ if locked_version == "0.0.0":
86
+ if self.target_version and self.target_version != "0.0.0":
87
+ locked_version = self.target_version
88
+ else:
89
+ print(
90
+ color_text(
91
+ "Error: Invalid version '0.0.0'. Aborting deployment.", "red"
92
+ )
93
+ )
94
+ raise ValueError("Invalid version '0.0.0'. Check pyproject.toml.")
95
+
96
+ self._clean_dist()
97
+
98
+ # Safe subprocess usage: arguments are trusted, no shell=True
99
+ try:
100
+ subprocess.run(
101
+ [sys.executable, "-m", "build"],
102
+ check=True,
103
+ cwd=self.base_dir, # nosec B603: args are trusted, no shell
104
+ )
105
+ except subprocess.CalledProcessError as err:
106
+ print(color_text(f"Build failed: {err}. Aborting deployment.", "red"))
107
+ raise RuntimeError("Build failed. Aborting deployment.") from err
108
+
109
+ dist_dir = self.base_dir / "dist"
110
+ dist_files = (
111
+ [
112
+ f
113
+ for f in dist_dir.glob("*")
114
+ if f.suffix in {".whl", ".tar.gz"} and f.is_file()
115
+ ]
116
+ if dist_dir.exists()
117
+ else []
118
+ )
119
+ if not dist_files:
120
+ print(
121
+ color_text(f"Error: No distribution files found in {dist_dir}.", "red")
122
+ )
123
+ raise RuntimeError("No distribution files found. Build may have failed.")
124
+
125
+ # Securely pass the token via environment variables so it
126
+ # does not appear in process listings.
127
+ env = os.environ.copy()
128
+ env["TWINE_USERNAME"] = "__token__"
129
+ env["TWINE_PASSWORD"] = self.token
130
+ env["TWINE_REPOSITORY"] = self.repository
131
+
132
+ cmd = [sys.executable, "-m", "twine", "upload"] + [str(f) for f in dist_files]
133
+
134
+ try:
135
+ subprocess.run(cmd, check=True, env=env) # nosec B603: args are trusted
136
+ print(
137
+ color_text(
138
+ f"Deployment successful! Version {locked_version} uploaded to {self.repository}.",
139
+ "green",
140
+ )
141
+ )
142
+ except subprocess.CalledProcessError as err:
143
+ print(
144
+ color_text(
145
+ f"Upload failed: {err}. Please check the error messages above.",
146
+ "red",
147
+ )
148
+ )
149
+ raise RuntimeError(
150
+ "Upload failed. Please check the error messages above."
151
+ ) from err
152
+
153
+
154
+ # Expose module under test-friendly alias used by tests (allows
155
+ # patching using the 'src.pyforge_deploy.builders.pypi' path)
156
+ sys.modules.setdefault("src.pyforge_deploy.builders.pypi", sys.modules[__name__])
@@ -0,0 +1,233 @@
1
+ import json
2
+ import os
3
+ import re
4
+ import sys
5
+ from typing import cast
6
+ from urllib.request import urlopen
7
+
8
+ import toml
9
+ from packaging.version import Version
10
+
11
+ from pyforge_deploy.colors import color_text
12
+
13
+
14
+ def find_project_root(current_path: str) -> str:
15
+ """Search upwards for pyproject.toml to determine project root."""
16
+ path = os.path.abspath(current_path)
17
+ while path and path != os.path.dirname(path):
18
+ if os.path.exists(os.path.join(path, "pyproject.toml")):
19
+ return path
20
+ path = os.path.dirname(path)
21
+ return os.getcwd()
22
+
23
+
24
+ def get_project_path() -> str:
25
+ """Return the project root path (searches upwards)."""
26
+ return find_project_root(os.getcwd())
27
+
28
+
29
+ def get_pyproject_path() -> str:
30
+ return os.path.join(get_project_path(), "pyproject.toml")
31
+
32
+
33
+ def get_cache_path(project_path: str, project_name: str) -> str:
34
+ cache_path = os.path.join(project_path, ".version_cache")
35
+ package_name = project_name.replace("-", "_")
36
+ about_path = os.path.join(project_path, "src", package_name, "__about__.py")
37
+ if os.path.exists(cache_path) and os.path.exists(about_path):
38
+ if os.path.getmtime(about_path) > os.path.getmtime(cache_path):
39
+ return about_path
40
+ return cache_path
41
+ if os.path.exists(about_path):
42
+ return about_path
43
+ return cache_path
44
+
45
+
46
+ def get_project_details() -> tuple[str, str]:
47
+ root = find_project_root(os.getcwd())
48
+ pyproject_path = os.path.join(root, "pyproject.toml")
49
+ if not os.path.exists(pyproject_path):
50
+ raise FileNotFoundError(f"pyproject.toml not found at {pyproject_path}")
51
+ data = toml.load(pyproject_path)
52
+ project = data.get("project", {})
53
+ name = project.get("name")
54
+ version = project.get("version")
55
+ dynamic = project.get("dynamic", [])
56
+ if not name:
57
+ raise ValueError("Project name missing in pyproject.toml")
58
+ if isinstance(dynamic, list) and "version" in dynamic:
59
+ return name, "dynamic"
60
+ return name, version or "0.0.0"
61
+
62
+
63
+ def fetch_latest_version(project_name: str, timeout: float = 5.0) -> str | None:
64
+ url = f"https://pypi.org/pypi/{project_name}/json"
65
+ if not url.startswith("https://"):
66
+ print(color_text(f"Invalid URL: {url}", "yellow"))
67
+ return None
68
+ for attempt in range(2):
69
+ try:
70
+ with urlopen(url, timeout=timeout) as response: # nosec B310
71
+ status = getattr(response, "status", 200)
72
+ if status != 200:
73
+ print(
74
+ color_text(
75
+ f"PyPI request failed with status {status}", "yellow"
76
+ )
77
+ )
78
+ continue
79
+ data = json.loads(response.read().decode("utf-8"))
80
+ return cast(str, data.get("info", {}).get("version"))
81
+ except Exception as e:
82
+ print(
83
+ color_text(f"PyPI fetch error (attempt {attempt + 1}): {e}", "yellow")
84
+ )
85
+ return None
86
+
87
+
88
+ def write_version_cache(cache_path: str, version: str) -> None:
89
+ try:
90
+ with open(cache_path, "w", encoding="utf-8") as f:
91
+ f.write(version)
92
+ except Exception as e:
93
+ print(color_text(f"Error writing version cache: {e}", "red"))
94
+
95
+
96
+ def calculate_next_version(current_version: str, bump_type: str = "patch") -> str:
97
+ """
98
+ Calculates the next version given bump type. Logs malformed input.
99
+ """
100
+ parts = current_version.split(".")
101
+ while len(parts) < 3:
102
+ parts.append("0")
103
+ try:
104
+ major = int(parts[0])
105
+ minor = int(parts[1])
106
+ patch = int(parts[2])
107
+ except ValueError:
108
+ print(color_text(f"Malformed version string: {current_version}", "red"))
109
+ raise ValueError(
110
+ f"Cannot auto-increment malformed version: {current_version}"
111
+ ) from None
112
+ if bump_type == "major":
113
+ major += 1
114
+ minor = 0
115
+ patch = 0
116
+ elif bump_type == "minor":
117
+ minor += 1
118
+ patch = 0
119
+ elif bump_type == "patch":
120
+ patch += 1
121
+ else:
122
+ print(f"Invalid bump_type: {bump_type}")
123
+ raise ValueError("bump_type must be 'major', 'minor', or 'patch'")
124
+ return f"{major}.{minor}.{patch}"
125
+
126
+
127
+ def read_local_version(cache_path: str) -> str | None:
128
+ """
129
+ Reads the local version from cache or about file. Logs malformed content.
130
+ """
131
+ if not os.path.exists(cache_path):
132
+ print(color_text(f"Cache file not found: {cache_path}", "yellow"))
133
+ return None
134
+ try:
135
+ with open(cache_path, encoding="utf-8") as f:
136
+ content = f.read().strip()
137
+ except Exception as e:
138
+ print(color_text(f"Error reading cache file: {e}", "red"))
139
+ return None
140
+ match = re.search(r'__version__\s*=\s*["\']([^"\']+)["\']', content)
141
+ if match:
142
+ return match.group(1)
143
+ if content and content[0].isdigit():
144
+ return content
145
+ print(color_text(f"Malformed cache content: {content}", "yellow"))
146
+ return None
147
+
148
+
149
+ def write_both_caches(project_path: str, project_name: str, version: str) -> None:
150
+ cache_path = os.path.join(project_path, ".version_cache")
151
+ try:
152
+ with open(cache_path, "w", encoding="utf-8") as f:
153
+ f.write(version)
154
+ except Exception as e:
155
+ print(f"Error writing cache: {e}")
156
+ package_name = project_name.replace("-", "_")
157
+ about_path = os.path.join(project_path, "src", package_name, "__about__.py")
158
+ about_dir = os.path.dirname(about_path)
159
+ if not os.path.exists(about_dir):
160
+ try:
161
+ os.makedirs(about_dir)
162
+ except Exception as e:
163
+ print(color_text(f"Error creating about directory: {e}", "red"))
164
+ return
165
+ try:
166
+ with open(about_path, "w", encoding="utf-8") as f:
167
+ f.write(f'__version__ = "{version}"\n')
168
+ except Exception as e:
169
+ print(color_text(f"Error writing about file: {e}", "red"))
170
+
171
+
172
+ def get_dynamic_version(
173
+ MANUAL_VERSION: str | None = None,
174
+ BUMP_TYPE: str | None = None,
175
+ AUTO_INCREMENT: bool = False,
176
+ ) -> str:
177
+ """
178
+ Determines the dynamic version, handling manual, bump, and auto-increment.
179
+ Logs errors and handles packaging fallback.
180
+ """
181
+ try:
182
+ project_name, project_version = get_project_details()
183
+ except Exception as e:
184
+ print(color_text(f"Warning: {e}. Falling back to 0.0.0", "yellow"))
185
+ return "0.0.0"
186
+
187
+ if project_version != "dynamic" and MANUAL_VERSION is None:
188
+ return project_version
189
+
190
+ root = find_project_root(os.getcwd())
191
+ if MANUAL_VERSION is not None:
192
+ write_both_caches(root, project_name, MANUAL_VERSION)
193
+ return MANUAL_VERSION
194
+
195
+ # Gather candidate sources for cached/about versions
196
+ package_name = project_name.replace("-", "_")
197
+ candidates = [
198
+ os.path.join(root, "src", package_name, "__about__.py"),
199
+ os.path.join(root, package_name, "__about__.py"),
200
+ os.path.join(root, ".version_cache"),
201
+ ]
202
+ cached_version = None
203
+ for candidate in candidates:
204
+ cached_version = read_local_version(candidate)
205
+ if cached_version:
206
+ break
207
+
208
+ pypi_version = fetch_latest_version(project_name)
209
+ base_version = "0.0.0"
210
+ if pypi_version and cached_version:
211
+ try:
212
+ base_version = (
213
+ pypi_version
214
+ if Version(pypi_version) > Version(cached_version)
215
+ else cached_version
216
+ )
217
+ except Exception as e:
218
+ print(color_text(f"Version comparison error: {e}", "yellow"))
219
+ base_version = pypi_version or cached_version or "0.0.0"
220
+ else:
221
+ base_version = pypi_version or cached_version or "0.0.0"
222
+
223
+ next_version = calculate_next_version(base_version, BUMP_TYPE or "patch")
224
+ if AUTO_INCREMENT or (BUMP_TYPE and BUMP_TYPE in {"major", "minor", "patch"}):
225
+ write_both_caches(root, project_name, next_version)
226
+ return next_version
227
+ return base_version
228
+
229
+
230
+ # Expose module under test-friendly alias used by tests
231
+ sys.modules.setdefault(
232
+ "src.pyforge_deploy.builders.version_engine", sys.modules[__name__]
233
+ )
pyforge_deploy/cli.py ADDED
@@ -0,0 +1,167 @@
1
+ """CLI module for pyforge_deploy."""
2
+
3
+ import argparse
4
+ import os
5
+ import sys
6
+
7
+ from pyforge_deploy.builders.docker import DockerBuilder
8
+ from pyforge_deploy.builders.docker_engine import detect_dependencies
9
+ from pyforge_deploy.builders.pypi import PyPIDistributor
10
+ from pyforge_deploy.builders.version_engine import get_dynamic_version
11
+
12
+ # ANSI color helpers for bold, meaningful output
13
+ _COLOR_CODES = {"red": "31", "green": "32", "yellow": "33", "blue": "34"}
14
+
15
+
16
+ def color_text(text: str, color: str) -> str:
17
+ code = _COLOR_CODES.get(color)
18
+ if not code:
19
+ return text
20
+ return f"\033[1;{code}m{text}\033[0m"
21
+
22
+
23
+ EXAMPLES = """
24
+ Examples:
25
+ pyforge-deploy docker-build --entry-point src/main.py --image-tag myapp:latest
26
+ pyforge-deploy deploy-pypi --bump patch
27
+ pyforge-deploy deploy-pypi --test --version 1.2.3
28
+ pyforge-deploy show-deps
29
+ pyforge-deploy show-version
30
+ """
31
+
32
+
33
+ def main() -> None:
34
+ parser = argparse.ArgumentParser(
35
+ description=(
36
+ "PyForge Deploy CLI\n"
37
+ "==================\n"
38
+ "Build Docker images, deploy Python packages to PyPI/TestPyPI,\n"
39
+ "and manage project versions."
40
+ ),
41
+ epilog=EXAMPLES,
42
+ formatter_class=argparse.RawDescriptionHelpFormatter,
43
+ )
44
+ subparsers = parser.add_subparsers(
45
+ dest="command", required=True, help="Available commands"
46
+ )
47
+
48
+ # Docker build command
49
+ docker_parser = subparsers.add_parser(
50
+ "docker-build",
51
+ help="Build a Docker image for the project.",
52
+ description="Generate a Dockerfile and build a Docker image for your project.",
53
+ )
54
+ docker_parser.add_argument(
55
+ "--entry-point",
56
+ type=str,
57
+ default=None,
58
+ metavar="PATH",
59
+ help="Main script to run in the container (e.g., src/main.py).",
60
+ )
61
+ docker_parser.add_argument(
62
+ "--image-tag",
63
+ type=str,
64
+ default=None,
65
+ metavar="TAG",
66
+ help="Custom tag for the Docker image (e.g., myapp:latest).",
67
+ )
68
+
69
+ def docker_build_handler(args: argparse.Namespace) -> None:
70
+ builder = DockerBuilder(entry_point=args.entry_point, image_tag=args.image_tag)
71
+ try:
72
+ builder.deploy()
73
+ except Exception as e:
74
+ if os.environ.get("PYFORGE_DEBUG"):
75
+ raise
76
+ print(color_text(f"Docker build failed: {e}", "red"))
77
+ sys.exit(1)
78
+
79
+ docker_parser.set_defaults(func=docker_build_handler)
80
+
81
+ # PyPI deploy command
82
+ pypi_parser = subparsers.add_parser(
83
+ "deploy-pypi",
84
+ help="Build and upload the package to PyPI or TestPyPI.",
85
+ description=(
86
+ "Build your package and upload it to PyPI or TestPyPI.\n"
87
+ "Supports version bumping and manual version setting."
88
+ ),
89
+ )
90
+ pypi_parser.add_argument(
91
+ "--test", action="store_true", help="Deploy to TestPyPI instead of PyPI."
92
+ )
93
+ pypi_parser.add_argument(
94
+ "--bump",
95
+ choices=["major", "minor", "patch"],
96
+ default=None,
97
+ metavar="TYPE",
98
+ help="Version bump type: major, minor, or patch.",
99
+ )
100
+ pypi_parser.add_argument(
101
+ "--version",
102
+ type=str,
103
+ default=None,
104
+ metavar="VERSION",
105
+ help="Manually set version to deploy (e.g., 1.2.3).",
106
+ )
107
+
108
+ def deploy_pypi_handler(args: argparse.Namespace) -> None:
109
+ distributor = PyPIDistributor(
110
+ target_version=args.version,
111
+ use_test_pypi=args.test,
112
+ bump_type=args.bump,
113
+ )
114
+ try:
115
+ distributor.deploy()
116
+ except Exception as e:
117
+ if os.environ.get("PYFORGE_DEBUG"):
118
+ raise
119
+ print(color_text(f"PyPI deployment failed: {e}", "red"))
120
+ sys.exit(1)
121
+
122
+ pypi_parser.set_defaults(func=deploy_pypi_handler)
123
+
124
+ # Show dependencies command
125
+ deps_parser = subparsers.add_parser(
126
+ "show-deps",
127
+ help="Show detected project dependencies.",
128
+ description="Display detected dependency files and pyproject.toml status.",
129
+ )
130
+
131
+ def show_deps_handler(args: argparse.Namespace) -> None:
132
+ report = detect_dependencies(os.getcwd())
133
+ print(color_text("\nDependency Report:", "blue"))
134
+ print(
135
+ f" {color_text('Has pyproject.toml:', 'yellow')} {report['has_pyproject']}"
136
+ )
137
+ req_files = (
138
+ ", ".join(report["requirement_files"])
139
+ if report["requirement_files"]
140
+ else "None"
141
+ )
142
+ print(f" {color_text('Requirement files:', 'yellow')} {req_files}")
143
+
144
+ deps_parser.set_defaults(func=show_deps_handler)
145
+
146
+ # Show version command
147
+ version_parser = subparsers.add_parser(
148
+ "show-version",
149
+ help="Show the current project version.",
150
+ description=(
151
+ "Display the current project version as determined by\n"
152
+ "pyproject.toml and version engine."
153
+ ),
154
+ )
155
+
156
+ def show_version_handler(args: argparse.Namespace) -> None:
157
+ version = get_dynamic_version()
158
+ print(color_text(f"\nCurrent project version: {version}", "green"))
159
+
160
+ version_parser.set_defaults(func=show_version_handler)
161
+
162
+ args = parser.parse_args()
163
+ args.func(args)
164
+
165
+
166
+ if __name__ == "__main__":
167
+ main()
@@ -0,0 +1,8 @@
1
+ _COLOR_CODES = {"red": "31", "green": "32", "yellow": "33", "blue": "34"}
2
+
3
+
4
+ def color_text(text: str, color: str) -> str:
5
+ code = _COLOR_CODES.get(color)
6
+ if not code:
7
+ return text
8
+ return f"\033[1;{code}m{text}\033[0m"
File without changes
@@ -0,0 +1,32 @@
1
+ FROM python:{{ python_version }}-slim
2
+
3
+ WORKDIR /app
4
+
5
+ RUN apt-get update && apt-get install -y --no-install-recommends \
6
+ build-essential \
7
+ && rm -rf /var/lib/apt/lists/*
8
+
9
+
10
+ {% if report.requirement_files %}
11
+ {% for file in report.requirement_files %}
12
+ COPY {{ file }} ./
13
+ {% endfor %}
14
+
15
+ RUN pip install --no-cache-dir --upgrade pip && \
16
+ {% for file in report.requirement_files %}pip install --no-cache-dir -r {{ file }} && {% endfor %} true
17
+ {% endif %}
18
+
19
+ COPY . .
20
+
21
+ {% if report.has_pyproject %}
22
+ RUN pip install --no-cache-dir .
23
+ {% endif %}
24
+
25
+ # Set Python to unbuffered mode
26
+ ENV PYTHONUNBUFFERED=1
27
+
28
+ {% if entry_point %}
29
+ CMD ["python", "{{ entry_point }}"]
30
+ {% else %}
31
+ CMD ["python"]
32
+ {% endif %}
@@ -0,0 +1,130 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyforge-deploy
3
+ Version: 0.1.0
4
+ Summary: Lightweight automation tool designed to streamline the transition from development to distribution.
5
+ Author-email: Ertan Tunç Türk <ertantuncturk61@gmail.com>
6
+ Maintainer-email: Ertan Tunç Türk <ertantuncturk61@gmail.com>
7
+ License-Expression: MIT
8
+ Project-URL: Homepage, https://github.com/ertanturk/pyforge-deploy
9
+ Project-URL: Repository, https://github.com/ertanturk/pyforge-deploy
10
+ Project-URL: Documentation, https://github.com/ertanturk/pyforge-deploy#readme
11
+ Project-URL: Issues, https://github.com/ertanturk/pyforge-deploy/issues
12
+ Keywords: python,docker,pypi,cli,devops,automation,workflow,packaging
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Intended Audience :: Education
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Typing :: Typed
20
+ Classifier: Topic :: Software Development :: Libraries
21
+ Classifier: Topic :: Software Development :: Testing
22
+ Classifier: Topic :: Software Development :: Build Tools
23
+ Requires-Python: >=3.12
24
+ Description-Content-Type: text/markdown
25
+ License-File: LICENSE
26
+ Provides-Extra: dev
27
+ Requires-Dist: ruff; extra == "dev"
28
+ Requires-Dist: mypy; extra == "dev"
29
+ Requires-Dist: pytest; extra == "dev"
30
+ Requires-Dist: pytest-cov; extra == "dev"
31
+ Requires-Dist: bandit; extra == "dev"
32
+ Requires-Dist: pip-audit; extra == "dev"
33
+ Requires-Dist: pre-commit; extra == "dev"
34
+ Requires-Dist: coverage; extra == "dev"
35
+ Requires-Dist: python-dotenv; extra == "dev"
36
+ Requires-Dist: types-toml; extra == "dev"
37
+ Requires-Dist: jinja2; extra == "dev"
38
+ Requires-Dist: build; extra == "dev"
39
+ Requires-Dist: twine; extra == "dev"
40
+ Requires-Dist: packaging; extra == "dev"
41
+ Dynamic: license-file
42
+
43
+ # pyforge-deploy
44
+
45
+ pyforge-deploy is an automation tool for building and publishing Python projects. It provides commands to build Docker images, generate project-specific Dockerfiles from templates, and build and upload packages to PyPI or TestPyPI. The tool is intended for use in CI or local development workflows where consistent packaging and deployment are required.
46
+
47
+ ## Features
48
+
49
+ - Detect project dependencies from `pyproject.toml` and fallback to `pip freeze` when necessary.
50
+ - Generate Dockerfiles using Jinja2 templates configured for the detected Python runtime and dependencies.
51
+ - Build source and wheel distributions and upload them to PyPI or TestPyPI with token-based authentication.
52
+ - Automatic version calculation and bumping (patch, minor, major) using local metadata and remote package data.
53
+ - CLI to drive common tasks: Docker build, PyPI deploy, show dependencies, and show version.
54
+
55
+ ## Tech Stack
56
+
57
+ - Language: Python 3.12+
58
+ - Templating: Jinja2
59
+ - Packaging: setuptools, build, twine
60
+ - Testing: pytest, unittest
61
+ - Linters and security: Ruff, Bandit, Mypy, pre-commit
62
+ - Container tooling: Docker
63
+
64
+ ## Installation
65
+
66
+ Clone the repository and install development dependencies in a virtual environment.
67
+
68
+ ```bash
69
+ git clone https://github.com/your-org/pyforge-deploy.git
70
+ cd pyforge-deploy
71
+ python3 -m venv .venv
72
+ source .venv/bin/activate
73
+ pip install --upgrade pip
74
+ pip install -r requirements-dev.txt
75
+ pip install .
76
+ ```
77
+
78
+ Optional: install Docker on the host if you will build images locally.
79
+
80
+ ## Usage
81
+
82
+ Use the provided CLI entrypoint to run common tasks. Run the help command to list available subcommands and options.
83
+
84
+ ```bash
85
+ pyforge-deploy --help
86
+ ```
87
+
88
+ Common commands
89
+
90
+ ```bash
91
+ # Build a Docker image using project templates and detected dependencies
92
+ pyforge-deploy docker-build --entry-point src/main.py --image-tag my-app:v1
93
+
94
+ # Build and publish to TestPyPI or PyPI
95
+ pyforge-deploy deploy-pypi --test --bump patch
96
+
97
+ # Show resolved dependencies
98
+ pyforge-deploy show-deps
99
+
100
+ # Show computed project version
101
+ pyforge-deploy show-version
102
+ ```
103
+
104
+ Configuration
105
+
106
+ - Place environment variables in a `.env` file or export them in CI. The primary variable used for publishing is `PYPI_TOKEN`.
107
+
108
+ ## Testing
109
+
110
+ Run the test suite with pytest.
111
+
112
+ ```bash
113
+ pytest
114
+ ```
115
+
116
+ The repository includes unit tests covering the CLI and builder components under the `tests` directory.
117
+
118
+ ## Development notes
119
+
120
+ - Source code lives in `src/pyforge_deploy` and follows a small builder pattern for Docker and PyPI actions.
121
+ - The version engine provides deterministic version bumping and caches results to avoid repeated network calls.
122
+
123
+ ## License
124
+
125
+ This project is released under the MIT License. See the `LICENSE` file for details.
126
+
127
+ ## Contributing
128
+
129
+ Contributions are welcome. For significant changes, open an issue to discuss the intended work, and submit pull requests with tests and documentation updates.
130
+
@@ -0,0 +1,17 @@
1
+ pyforge_deploy/__about__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
2
+ pyforge_deploy/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ pyforge_deploy/cli.py,sha256=xoGXivVSwdxnMqrnzNgwbkHXErRycshbDYslBNKOR54,5249
4
+ pyforge_deploy/colors.py,sha256=PEib-8-421xZIxOZupVDup-e2eT13l3JTAKJfJRyvwo,236
5
+ pyforge_deploy/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ pyforge_deploy/builders/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ pyforge_deploy/builders/docker.py,sha256=E4eu6dJkVfdd6VguKT7xrGDR5VTAUs3cR0mRiZf5qE8,4133
8
+ pyforge_deploy/builders/docker_engine.py,sha256=knmMkutix3XTIklPXdoT1w8iMgTOyYPd1xVQIPB1GJE,3412
9
+ pyforge_deploy/builders/pypi.py,sha256=-y2I1oCjoVhxlnZ8bKr5SfGeYqLWbijKYhojYIsg-ho,5577
10
+ pyforge_deploy/builders/version_engine.py,sha256=_9YDIV8dcEv-V7nhT2WKt8MxcocHK73DxfHGp57iMRA,8090
11
+ pyforge_deploy/templates/Dockerfile.j2,sha256=uWneh8aLyUi9Hv-ALBglpPCisk0C_v8yMI-XZ8QVFdY,696
12
+ pyforge_deploy-0.1.0.dist-info/licenses/LICENSE,sha256=zSOmBqzklofIYHdtgFQ2KWlOu3qqFBfBEZFafbepHfQ,1074
13
+ pyforge_deploy-0.1.0.dist-info/METADATA,sha256=S2TxKFX9SmJKINM4JwqZujLHFDKWmydh2rbH-Ezv7xA,4702
14
+ pyforge_deploy-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
15
+ pyforge_deploy-0.1.0.dist-info/entry_points.txt,sha256=wA0vEmQE1HwVRpFISfBHvhzq9jsAeSZZoG5qs5922o4,59
16
+ pyforge_deploy-0.1.0.dist-info/top_level.txt,sha256=WwS59Q3MJFi2VIL6Sd0REX9DjPUJwvpt2d5usF16MXo,15
17
+ pyforge_deploy-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pyforge-deploy = pyforge_deploy.cli:main
@@ -0,0 +1,25 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ertan Tunç Türk
4
+
5
+ Permission is hereby granted, free of charge, to any person
6
+ obtaining a copy of this software and associated documentation
7
+ files (the "Software"), to deal in the Software without
8
+ restriction, including without limitation the rights to use,
9
+ copy, modify, merge, publish, distribute, sublicense, and/or
10
+ sell copies of the Software, and to permit persons to whom
11
+ the Software is furnished to do so, subject to the following
12
+ conditions:
13
+
14
+ The above copyright notice and this permission notice shall
15
+ be included in all copies or substantial portions of the
16
+ Software.
17
+
18
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY
19
+ KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
20
+ WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
21
+ PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS
22
+ OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR
23
+ OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
24
+ OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
25
+ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1 @@
1
+ pyforge_deploy