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.
- pyforge_deploy/__about__.py +1 -0
- pyforge_deploy/__init__.py +0 -0
- pyforge_deploy/builders/__init__.py +0 -0
- pyforge_deploy/builders/docker.py +110 -0
- pyforge_deploy/builders/docker_engine.py +105 -0
- pyforge_deploy/builders/pypi.py +156 -0
- pyforge_deploy/builders/version_engine.py +233 -0
- pyforge_deploy/cli.py +167 -0
- pyforge_deploy/colors.py +8 -0
- pyforge_deploy/py.typed +0 -0
- pyforge_deploy/templates/Dockerfile.j2 +32 -0
- pyforge_deploy-0.1.0.dist-info/METADATA +130 -0
- pyforge_deploy-0.1.0.dist-info/RECORD +17 -0
- pyforge_deploy-0.1.0.dist-info/WHEEL +5 -0
- pyforge_deploy-0.1.0.dist-info/entry_points.txt +2 -0
- pyforge_deploy-0.1.0.dist-info/licenses/LICENSE +25 -0
- pyforge_deploy-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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()
|
pyforge_deploy/colors.py
ADDED
pyforge_deploy/py.typed
ADDED
|
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,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
|