Skip to content

Commit e8beb8e

Browse files
committed
Refactored, added --link
1 parent 144d9cb commit e8beb8e

File tree

6 files changed

+297
-10
lines changed

6 files changed

+297
-10
lines changed

applecrate/build.py

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
"""Build a macOS installer package"""
2+
3+
from __future__ import annotations
4+
5+
import pathlib
6+
import shutil
7+
import subprocess
8+
9+
import click
10+
from click import echo
11+
12+
from .utils import copy_and_create_parents
13+
14+
BUILD_DIR = pathlib.Path("build/darwin")
15+
16+
17+
def clean_build_dir(build_dir: pathlib.Path):
18+
"""Clean the build directory."""
19+
if not build_dir.exists():
20+
return
21+
22+
for file in build_dir.iterdir():
23+
if file.is_file():
24+
file.unlink()
25+
else:
26+
shutil.rmtree(file)
27+
28+
29+
def create_build_dirs(build_dir: pathlib.Path):
30+
"""Create build directory."""
31+
32+
print(f"Creating build directory {build_dir}")
33+
# files will be created in the build directory
34+
build_dir.mkdir(exist_ok=True, parents=True, mode=0o755)
35+
36+
# Resources contains the welcome and conclusion HTML files
37+
resources = build_dir / "Resources"
38+
resources.mkdir(exist_ok=True, mode=0o755)
39+
echo(f"Created {resources}")
40+
41+
# scripts contains postinstall and preinstall scripts
42+
scripts = build_dir / "scripts"
43+
scripts.mkdir(exist_ok=True, mode=0o755)
44+
echo(f"Created {scripts}")
45+
46+
# darwinpkg subdirectory is the root for files to be in installed
47+
darwinpkg = build_dir / "darwinpkg"
48+
darwinpkg.mkdir(exist_ok=True, mode=0o755)
49+
echo(f"Created {darwinpkg}")
50+
51+
# package subdirectory is the root for the macOS installer package
52+
package = build_dir / "package"
53+
package.mkdir(exist_ok=True, mode=0o755)
54+
echo(f"Created {package}")
55+
56+
# pkg subdirectory is location of final macOS installer product
57+
pkg = build_dir / "pkg"
58+
pkg.mkdir(exist_ok=True, mode=0o755)
59+
echo(f"Created {pkg}")
60+
61+
62+
def check_dependencies():
63+
"""Check for dependencies."""
64+
echo("Checking for dependencies.")
65+
if not shutil.which("pkgbuild"):
66+
raise click.ClickException("pkgbuild is not installed")
67+
if not shutil.which("productbuild"):
68+
raise click.ClickException("productbuild is not installed")
69+
70+
71+
def build_package(app: str, version: str, target_directory: str):
72+
"""Build the macOS installer package."""
73+
pkg = f"{target_directory}/package/{app}.pkg"
74+
proc = subprocess.run(
75+
[
76+
"pkgbuild",
77+
"--identifier",
78+
f"org.{app}.{version}",
79+
"--version",
80+
version,
81+
"--scripts",
82+
f"{target_directory}/scripts",
83+
"--root",
84+
f"{target_directory}/darwinpkg",
85+
pkg,
86+
],
87+
stdout=subprocess.PIPE,
88+
stderr=subprocess.PIPE,
89+
)
90+
if proc.returncode != 0:
91+
raise click.ClickException(f"pkgbuild failed: {proc.returncode} {proc.stderr.decode('utf-8')}")
92+
echo(f"Created {pkg}")
93+
94+
95+
def build_product(app: str, version: str, target_directory: str):
96+
"""Build the macOS installer package."""
97+
product = f"{target_directory}/pkg/{app}-{version}.pkg"
98+
proc = subprocess.run(
99+
[
100+
"productbuild",
101+
"--distribution",
102+
f"{target_directory}/Distribution",
103+
"--resources",
104+
f"{target_directory}/Resources",
105+
"--package-path",
106+
f"{target_directory}/package",
107+
product,
108+
],
109+
stdout=subprocess.PIPE,
110+
stderr=subprocess.PIPE,
111+
)
112+
if proc.returncode != 0:
113+
raise click.ClickException(f"productbuild failed: {proc.returncode} {proc.stderr.decode('utf-8')}")
114+
echo(f"Created {product}")
115+
116+
117+
def stage_install_files(src: str, dest: str, build_dir: pathlib.Path):
118+
"""Stage install files in the build directory."""
119+
src = pathlib.Path(src)
120+
try:
121+
dest = pathlib.Path(dest).relative_to("/")
122+
except ValueError:
123+
dest = pathlib.Path(dest)
124+
target = build_dir / "darwinpkg" / pathlib.Path(dest)
125+
if src.is_file():
126+
copy_and_create_parents(src, target)
127+
else:
128+
shutil.copytree(src, target)

applecrate/cli.py

Lines changed: 54 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,10 @@ def cli():
3131
pass
3232

3333

34-
@cli.command()
35-
def init():
36-
"""Create a new applecrate project."""
37-
echo("Creating a new applecrate project.")
34+
# @cli.command()
35+
# def init():
36+
# """Create a new applecrate project."""
37+
# echo("Creating a new applecrate project.")
3838

3939

4040
# @cli.command()
@@ -104,6 +104,16 @@ def init():
104104
"will install the file 'dist/app' to '/usr/local/bin/app-1.0.0' "
105105
"if --app=app and --version=1.0.0.",
106106
)
107+
@click.option(
108+
"--link",
109+
"-k",
110+
metavar="SRC TARGET",
111+
nargs=2,
112+
multiple=True,
113+
help="Create a symbolic link from SRC to DEST after installation. "
114+
"SRC and TARGET must be absolute paths and both may include template variables {{ app }} and {{ version }}. "
115+
'For example: `--link "/Library/Application Support/{{ app }}/{{ version }}/app" "/usr/local/bin/{{ app }}-{{ version }}"` ',
116+
)
107117
@click.option(
108118
"--post-install",
109119
"-p",
@@ -136,6 +146,7 @@ def build(**kwargs):
136146
no_uninstall = kwargs["no_uninstall"]
137147
url = kwargs["url"]
138148
install = kwargs["install"]
149+
link = kwargs["link"]
139150
license = kwargs["license"]
140151
banner = kwargs["banner"]
141152
post_install = kwargs["post_install"]
@@ -148,6 +159,8 @@ def build(**kwargs):
148159
"url": url,
149160
"install": install,
150161
"banner": banner,
162+
"link": link,
163+
"post_install": post_install,
151164
}
152165

153166
echo(f"Building installer package for {app} version {version}.")
@@ -182,16 +195,25 @@ def build(**kwargs):
182195
pathlib.Path(target).chmod(0o755)
183196
echo(f"Created {target}")
184197

185-
echo("Creating post-install script")
198+
echo("Creating post-install scripts")
186199
target = BUILD_DIR / "scripts" / "postinstall"
187-
if post_install:
188-
copy_and_create_parents(post_install, target)
189-
else:
190-
template = get_template("postinstall")
191-
render_template(template, data, target)
200+
template = get_template("postinstall")
201+
render_template(template, data, target)
192202
pathlib.Path(target).chmod(0o755)
193203
echo(f"Created {target}")
194204

205+
target = BUILD_DIR / "scripts" / "links"
206+
template = get_template("links")
207+
render_template(template, data, target)
208+
pathlib.Path(target).chmod(0o755)
209+
echo(f"Created {target}")
210+
211+
if post_install:
212+
target = BUILD_DIR / "scripts" / "postinstall_user"
213+
copy_and_create_parents(post_install, target)
214+
pathlib.Path(target).chmod(0o755)
215+
echo(f"Created {target}")
216+
195217
if banner:
196218
echo("Copying banner image")
197219
target = BUILD_DIR / "Resources" / "banner.png"
@@ -226,6 +248,16 @@ def render_build_kwargs(kwargs: dict[str, Any]) -> dict[str, Any]:
226248
dest = pathlib.Path(template.render(app=app, version=version))
227249
new_install.append((src, dest))
228250
rendered["install"] = new_install
251+
252+
if link := rendered.get("link"):
253+
new_link = []
254+
for src, target in link:
255+
src_template = Template(str(src))
256+
target_template = Template(str(target))
257+
src = pathlib.Path(src_template.render(app=app, version=version))
258+
target = pathlib.Path(target_template.render(app=app, version=version))
259+
new_link.append((src, target))
260+
rendered["link"] = new_link
229261
return rendered
230262

231263

@@ -276,6 +308,18 @@ def validate_build_kwargs(**kwargs):
276308
pathlib_install.append((src, dest))
277309
kwargs["install"] = pathlib_install
278310

311+
if link := kwargs.get("link"):
312+
pathlib_link = []
313+
for src, target in link:
314+
src = pathlib.Path(src)
315+
target = pathlib.Path(target)
316+
if not src.is_absolute():
317+
raise ValueError(f"Link source {src} must be an absolute path")
318+
if not target.is_absolute():
319+
raise ValueError(f"Link target {target} must be an absolute path")
320+
pathlib_link.append((src, target))
321+
kwargs["link"] = pathlib_link
322+
279323
if banner := kwargs.get("banner"):
280324
banner = pathlib.Path(banner)
281325
if banner.suffix.lower() != ".png":

applecrate/template_utils.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
"""Utilities for working with templates"""
2+
3+
from __future__ import annotations
4+
5+
import pathlib
6+
7+
import markdown2
8+
from click import echo
9+
from jinja2 import Environment, PackageLoader, Template, select_autoescape
10+
11+
# extra features to support for Markdown to HTML conversion with markdown2
12+
MARKDOWN_EXTRAS = ["fenced-code-blocks", "footnotes", "tables"]
13+
14+
15+
def load_template_from_file(path: pathlib.Path) -> Template:
16+
"""Load a Jinja2 template from a file."""
17+
with open(path) as file:
18+
return Template(file.read())
19+
20+
21+
def get_template(name: str) -> Template:
22+
"""Load a Jinja2 template from the package."""
23+
24+
env = Environment(loader=PackageLoader("applecrate", "templates"), autoescape=select_autoescape())
25+
return env.get_template(name)
26+
27+
28+
def render_markdown_template(template: Template, data: dict[str, str], output: pathlib.Path):
29+
"""Render and save a Jinja2 template to a file, converting markdown to HTML."""
30+
md = template.render(**data)
31+
html = markdown2.markdown(md, extras=MARKDOWN_EXTRAS)
32+
head = (
33+
'<head> <meta charset="utf-8" /> <style> body { font-family: Helvetica, sans-serif; font-size: 14px; } </style> </head>'
34+
)
35+
html = f"<!DOCTYPE html>\n<html>\n{head}\n<body>\n{html}\n</body>\n</html>"
36+
output.parent.mkdir(parents=True, exist_ok=True)
37+
with open(output, "w") as file:
38+
file.write(html)
39+
40+
41+
def render_template(template: Template, data: dict[str, str], output: pathlib.Path):
42+
"""Render and save a Jinja2 template to a file."""
43+
rendered = template.render(**data)
44+
output.parent.mkdir(parents=True, exist_ok=True)
45+
with open(output, "w") as file:
46+
file.write(rendered)
47+
48+
49+
def create_html_file(
50+
input_path: pathlib.Path | None,
51+
output_path: pathlib.Path,
52+
data: dict[str, str],
53+
default_template: str,
54+
):
55+
"""Create an HTML file from a markdown or HTML file and render with Jinja2."""
56+
57+
if input_path:
58+
template = load_template_from_file(input_path)
59+
else:
60+
template = get_template(default_template)
61+
62+
if not input_path or input_path.suffix.lower() in [".md", ".markdown"]:
63+
# passed a markdown file or no file at all
64+
render_markdown_template(template, data, output_path)
65+
else:
66+
render_template(template, data, output_path)
67+
68+
echo(f"Created {output_path}")

applecrate/templates/links

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
#!/bin/bash
2+
3+
# Create links specified by the user as part of post-install
4+
5+
{% for source, target in link %}
6+
ln -s "{{ source }}" "{{ target }}"
7+
{% endfor %}

applecrate/templates/postinstall

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
#!/bin/bash
22

3+
# Perform post installation tasks here
4+
5+
# Create links if needed
6+
{% if link %}
7+
./links
8+
{% endif %}
9+
310
#Custermize this for your application
411
# APPLICATION_FILE_PATH=bin/wso2server.sh
512

applecrate/utils.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
"""Utilities for applecrate"""
2+
3+
from __future__ import annotations
4+
5+
import copy
6+
import pathlib
7+
import shutil
8+
from typing import Any
9+
10+
from click import echo
11+
12+
13+
def set_from_defaults(kwargs: dict[str, Any], defaults: dict[str, Any]) -> dict[str, Any]:
14+
"""Set values in kwargs from defaults if not provided or set to falsy value.
15+
16+
Args:
17+
kwargs: The dictionary of keyword arguments to set.
18+
defaults: The default values to set if not provided or set to a falsy value.
19+
20+
Returns: A new dictionary with the updated values.
21+
"""
22+
updated = copy.deepcopy(kwargs)
23+
for key, value in defaults.items():
24+
if key not in updated or not updated[key]:
25+
updated[key] = value
26+
return updated
27+
28+
29+
def copy_and_create_parents(src: pathlib.Path, dst: pathlib.Path):
30+
"""Copy a file to a destination and create any necessary parent directories."""
31+
echo(f"Copying {src} to {dst}")
32+
dst.parent.mkdir(parents=True, exist_ok=True)
33+
shutil.copy(src, dst)

0 commit comments

Comments
 (0)