Skip to content

Commit 6972a55

Browse files
authored
Merge pull request #959 from googlefonts/packager-build
gftools-packager --build-from-source
2 parents b8d0d44 + 81ae09e commit 6972a55

File tree

3 files changed

+348
-7
lines changed

3 files changed

+348
-7
lines changed
Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
Google_Fonts_has_family,
4141
has_gh_token,
4242
)
43+
from gftools.packager.build import build_to_directory
4344
import sys
4445
from gftools.push.trafficjam import TRAFFIC_JAM_ID
4546

@@ -325,13 +326,22 @@ def assets_are_same(src: Path, dst: Path) -> bool:
325326

326327

327328
def package_family(
328-
family_path: Path, metadata: fonts_pb2.FamilyProto, latest_release=False
329+
family_path: Path,
330+
metadata: fonts_pb2.FamilyProto,
331+
latest_release=False,
332+
build_from_source=False,
333+
their_venv=False,
334+
**kwargs,
329335
):
330336
"""Create a family into a google/fonts repo."""
331-
log.info(f"Downloading family to '{family_path}'")
332337
with tempfile.TemporaryDirectory() as tmp:
333338
tmp_dir = Path(tmp)
334-
download_assets(metadata, tmp_dir, latest_release)
339+
if build_from_source:
340+
log.info(f"Building '{metadata.name}' from source")
341+
build_to_directory(tmp_dir, family_path, metadata, their_venv=their_venv)
342+
else:
343+
log.info(f"Downloading family to '{family_path}'")
344+
download_assets(metadata, tmp_dir, latest_release)
335345
if assets_are_same(tmp_dir, family_path):
336346
raise ValueError(f"'{family_path}' already has latest files, Aborting.")
337347
# rm existing fonts. Sometimes the font count will change if a family
@@ -597,6 +607,7 @@ def make_package(
597607
base_repo: str = "google",
598608
head_repo: str = "google",
599609
latest_release: bool = False,
610+
build_from_source: bool = False,
600611
issue_number=None,
601612
**kwargs,
602613
):
@@ -665,8 +676,7 @@ def make_package(
665676
# All font families must have tagging data. This data helps users on Google
666677
# Fonts find font families. It's enabled by default since it's a hard
667678
# requirements set by management.
668-
tags = GFTags()
669-
if not skip_tags and not tags.has_family(metadata.name):
679+
if not skip_tags and not GFTags().has_family(metadata.name):
670680
raise ValueError(
671681
f"'{metadata.name}' does not have family tagging data! "
672682
"Please complete the following form, "
@@ -678,7 +688,9 @@ def make_package(
678688

679689
with current_git_state(repo, family_path):
680690
branch = create_git_branch(metadata, repo, head_repo)
681-
packaged = package_family(family_path, metadata, latest_release)
691+
packaged = package_family(
692+
family_path, metadata, latest_release, build_from_source, **kwargs
693+
)
682694
title, msg, branch = commit_family(
683695
branch, family_path, metadata, repo, head_repo, issue_number
684696
)

Lib/gftools/packager/build.py

Lines changed: 318 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,318 @@
1+
import contextlib
2+
import os
3+
import re
4+
import selectors
5+
import shutil
6+
import subprocess
7+
import tempfile
8+
from pathlib import Path
9+
from typing import Dict, List
10+
from venv import EnvBuilder
11+
12+
import git
13+
import yaml
14+
from rich import progress
15+
from rich.progress import Progress
16+
17+
import gftools.fonts_public_pb2 as fonts_pb2
18+
19+
# Python <3.11
20+
if not hasattr(contextlib, "chdir"):
21+
from contextlib import AbstractContextManager
22+
23+
class chdir(AbstractContextManager):
24+
"""Non thread-safe context manager to change the current working directory."""
25+
26+
def __init__(self, path):
27+
self.path = path
28+
self._old_cwd = []
29+
30+
def __enter__(self):
31+
self._old_cwd.append(os.getcwd())
32+
os.chdir(self.path)
33+
34+
def __exit__(self, *excinfo):
35+
os.chdir(self._old_cwd.pop())
36+
37+
contextlib.chdir = chdir
38+
39+
40+
class GitRemoteProgress(git.RemoteProgress):
41+
OP_CODES = [
42+
"BEGIN",
43+
"CHECKING_OUT",
44+
"COMPRESSING",
45+
"COUNTING",
46+
"END",
47+
"FINDING_SOURCES",
48+
"RECEIVING",
49+
"RESOLVING",
50+
"WRITING",
51+
]
52+
OP_CODE_MAP = {
53+
getattr(git.RemoteProgress, _op_code): _op_code for _op_code in OP_CODES
54+
}
55+
56+
def __init__(self, progressbar, task, name) -> None:
57+
super().__init__()
58+
self.progressbar = progressbar
59+
self.task = task
60+
self.name = name
61+
self.curr_op = None
62+
63+
@classmethod
64+
def get_curr_op(cls, op_code: int) -> str:
65+
"""Get OP name from OP code."""
66+
# Remove BEGIN- and END-flag and get op name
67+
op_code_masked = op_code & cls.OP_MASK
68+
return cls.OP_CODE_MAP.get(op_code_masked, "?").title()
69+
70+
def update(
71+
self,
72+
op_code: int,
73+
cur_count: str | float,
74+
max_count: str | float | None = None,
75+
message: str | None = "",
76+
) -> None:
77+
if not self.progressbar:
78+
return
79+
# Start new bar on each BEGIN-flag
80+
if op_code & self.BEGIN:
81+
self.curr_op = self.get_curr_op(op_code)
82+
# logger.info("Next: %s", self.curr_op)
83+
self.progressbar.update(
84+
self.task,
85+
description="[yellow] " + self.curr_op + " " + self.name,
86+
total=max_count,
87+
)
88+
89+
self.progressbar.update(
90+
task_id=self.task,
91+
completed=cur_count,
92+
message=message,
93+
)
94+
95+
96+
def find_config_yaml(source_dir: Path):
97+
configs = []
98+
for path in source_dir.glob("sources/*.y*l"):
99+
if not (str(path).endswith(".yaml") or str(path).endswith(".yml")):
100+
continue
101+
content = yaml.load(path.read_text(), Loader=yaml.Loader)
102+
if "sources" not in content:
103+
continue
104+
configs.append(path)
105+
if configs:
106+
return configs[0]
107+
108+
109+
def find_sources(source_dir: Path) -> List[Path]:
110+
# Extensions in order of preference
111+
for extension in [".glyphs", ".glyphspackage", ".designspace", ".ufo"]:
112+
sources = list(source_dir.glob("sources/*" + extension))
113+
if sources:
114+
return sources
115+
return []
116+
117+
118+
class SourceBuilder:
119+
def __init__(
120+
self,
121+
destination: Path,
122+
family_path: Path,
123+
metadata: fonts_pb2.FamilyProto,
124+
their_venv: bool = False,
125+
):
126+
self.destination = destination
127+
self.family_path = family_path
128+
self.metadata = metadata
129+
self.name = metadata.name
130+
self.their_venv = their_venv
131+
self.progressbar = None
132+
self.source_dir = tempfile.TemporaryDirectory()
133+
134+
def setup_venv(self, source_dir: Path):
135+
venv_task = self.progressbar.add_task(
136+
"[yellow]Setup venv", total=500, visible=False
137+
)
138+
self.progressbar.update(
139+
venv_task,
140+
description="[yellow] Setting up venv for " + self.name + "...",
141+
completed=0,
142+
visible=True,
143+
)
144+
if (source_dir / "Makefile").exists():
145+
with contextlib.chdir(source_dir):
146+
# self.progressbar.console.print(
147+
# "[yellow]Running make venv in " + str(source_dir)
148+
# )
149+
rc = self.run_command_with_callback(
150+
["make", "venv"],
151+
lambda line: self.progressbar.update(venv_task, advance=1),
152+
)
153+
elif (source_dir / "requirements.txt").exists():
154+
builder = EnvBuilder(system_site_packages=False, with_pip=True)
155+
builder.create(str(source_dir / "venv"))
156+
self.progressbar.update(venv_task, completed=10)
157+
# self.progressbar.console.print(
158+
# "[yellow]Running pip install in " + str(source_dir)
159+
# )
160+
with contextlib.chdir(source_dir):
161+
rc = self.run_command_with_callback(
162+
["venv/bin/pip", "install", "-r", "requirements.txt"],
163+
lambda line: self.progressbar.update(venv_task, advance=1),
164+
)
165+
else:
166+
raise ValueError(
167+
"--their-venv was provided but no Makefile or requirements.txt upstream"
168+
)
169+
if rc != 0:
170+
self.progressbar.console.print(
171+
"[red]Error setting up venv for " + self.name
172+
)
173+
raise ValueError("Venv setup failed")
174+
self.progressbar.remove_task(venv_task)
175+
176+
def local_overrides(
177+
self, upstream: Path, downstream: Path, overrides: Dict[str, str]
178+
):
179+
for source, dest in overrides.items():
180+
if (downstream / source).exists():
181+
dest_path = upstream / dest
182+
os.makedirs(dest_path.parent, exist_ok=True)
183+
self.progressbar.console.print("[grey]Using our " + source)
184+
shutil.copy(downstream / source, dest_path)
185+
186+
def build(self):
187+
with Progress(
188+
progress.TimeElapsedColumn(),
189+
progress.TextColumn("[progress.description]{task.description}"),
190+
progress.BarColumn(),
191+
progress.TextColumn("{task.completed}/{task.total}"),
192+
progress.TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
193+
progress.TimeRemainingColumn(),
194+
) as self.progressbar:
195+
with tempfile.TemporaryDirectory() as source_dir:
196+
source_dir = Path(source_dir)
197+
self.clone_source(source_dir)
198+
self.local_overrides(
199+
source_dir,
200+
self.family_path,
201+
{
202+
"config.yaml": "sources/config.yaml",
203+
"requirements.txt": "requirements.txt",
204+
},
205+
)
206+
207+
if not (source_dir / "sources").exists():
208+
raise ValueError(f"Could not find sources directory in {self.name}")
209+
if self.their_venv:
210+
self.setup_venv(source_dir)
211+
212+
# Locate the config.yaml file or first source
213+
arg = find_config_yaml(source_dir)
214+
if not arg:
215+
sources = find_sources(source_dir)
216+
if not sources:
217+
raise ValueError(
218+
f"Could not find any sources in {self.metadata.source}"
219+
)
220+
arg = sources[0]
221+
222+
with contextlib.chdir(source_dir):
223+
if self.their_venv:
224+
buildcmd = ["venv/bin/gftools-builder", str(arg)]
225+
else:
226+
buildcmd = ["gftools-builder", str(arg)]
227+
self.run_build_command(buildcmd)
228+
self.copy_files()
229+
230+
def run_command_with_callback(self, cmd, callback):
231+
process = subprocess.Popen(
232+
cmd,
233+
stdout=subprocess.PIPE,
234+
stderr=subprocess.PIPE,
235+
)
236+
sel = selectors.DefaultSelector()
237+
sel.register(process.stdout, selectors.EVENT_READ)
238+
sel.register(process.stderr, selectors.EVENT_READ)
239+
ok = True
240+
stdoutlines = []
241+
stderrlines = []
242+
while ok:
243+
for key, _val1 in sel.select():
244+
line = key.fileobj.readline()
245+
if not line:
246+
ok = False
247+
break
248+
if key.fileobj is process.stdout:
249+
callback(line)
250+
elif key.fileobj is process.stderr:
251+
stderrlines.append(line)
252+
else:
253+
stdoutlines.append(line)
254+
rc = process.wait()
255+
if rc != 0:
256+
for line in stdoutlines:
257+
self.progressbar.console.print(line.decode("utf-8"), end="")
258+
for line in stderrlines:
259+
self.progressbar.console.print("[red]" + line.decode("utf8"), end="")
260+
return rc
261+
262+
def run_build_command(self, buildcmd):
263+
build_task = self.progressbar.add_task("[green]Build " + self.name, total=1)
264+
265+
def progress_callback(line):
266+
if m := re.match(r"^\[(\d+)/(\d+)\]", line.decode("utf8")):
267+
self.progressbar.update(
268+
build_task, completed=int(m.group(1)), total=int(m.group(2))
269+
)
270+
271+
if self.run_command_with_callback(buildcmd, progress_callback) != 0:
272+
self.progressbar.console.print("[red]Error building " + self.name)
273+
raise ValueError("Build failed")
274+
else:
275+
self.progressbar.console.print("[green]Built " + self.name)
276+
277+
def clone_source(
278+
self,
279+
builddir: Path,
280+
):
281+
clone_task = self.progressbar.add_task(
282+
"[yellow]Clone", total=100, visible=False
283+
)
284+
self.progressbar.update(
285+
clone_task,
286+
description="[yellow] Cloning " + self.name + "...",
287+
completed=0,
288+
visible=True,
289+
)
290+
git.Repo.clone_from(
291+
url=self.metadata.source.repository_url,
292+
to_path=builddir,
293+
depth=1,
294+
progress=GitRemoteProgress(self.progressbar, clone_task, self.name),
295+
)
296+
self.progressbar.remove_task(clone_task)
297+
298+
def copy_files(self):
299+
# We are sat in the build directory
300+
for item in self.metadata.source.files:
301+
in_fp = Path(item.source_file)
302+
if not in_fp.exists():
303+
raise ValueError(
304+
f"Expected to copy {item.source_file} but it was not found after build"
305+
)
306+
out_fp = Path(self.destination / item.dest_file)
307+
if not out_fp.parent.exists():
308+
os.makedirs(out_fp.parent, exist_ok=True)
309+
shutil.copy(in_fp, out_fp)
310+
311+
312+
def build_to_directory(
313+
destination: Path,
314+
family_path: Path,
315+
metadata: fonts_pb2.FamilyProto,
316+
their_venv: bool = False,
317+
):
318+
SourceBuilder(destination, family_path, metadata, their_venv).build()

0 commit comments

Comments
 (0)