Skip to content

Instantly share code, notes, and snippets.

@BrianHicks
Last active April 27, 2023 20:57
Show Gist options
  • Save BrianHicks/7ef7818ab0a8d907b5ba93f5c739f05f to your computer and use it in GitHub Desktop.
Save BrianHicks/7ef7818ab0a8d907b5ba93f5c739f05f to your computer and use it in GitHub Desktop.
#!/usr/bin/env python3
"""
Build an Elm app, but isolate the working directory so that multiple Elm
compiler invocations can run in parallel without corrupting `.elmi` files.
"""
from argparse import ArgumentParser, ArgumentTypeError
import json
import logging
import os
import os.path
import shutil
from subprocess import Popen, PIPE
import sys
import tempfile
def symlink_if_necessary(source, target):
"""
Do the same thing as `os.symlink`, but with these edge cases checked:
- If the target already exists and points to the source, do nothing.
- If the target already exists and points somewhere else, remove it
and relink.
"""
if os.path.exists(target):
logging.debug(f"`{target}` already exists")
if os.readlink(target) == source:
logging.debug(f"`{target}` already points to `{source}`")
return
os.unlink(target)
logging.info(f"linking `{target}` to `{source}`")
os.symlink(source, target)
def write_if_necessary(target, content):
"""
Read the target file and check the content. If it's the same, don't write.
Otherwise, replace it.
"""
if os.path.exists(target):
with open(target, "r") as fh:
if fh.read() == content:
logging.debug(f"`{target}` already had the requested content.")
return
logging.info(f"writing `{target}`")
with open(target, "w") as fh:
fh.write(content)
def run_docs(args):
"""
Compile JSON docs for an Elm library.
"""
logging.info(f"copying {args.elm_json} to {args.build_dir}")
shutil.copy(args.elm_json, os.path.join(args.build_dir, "elm.json"))
# for libraries, the Elm compiler always assumes the source lives in a
# directory named "src". We have one of those from the arguments, so let's
# set it up under the build root. For the sake of being able to reuse this
# build directory's `elm-stuff`, we take care to repoint the symlink if
# necessary.
symlink_if_necessary(os.path.abspath(args.src), os.path.join(args.build_dir, "src"))
command = [
args.elm_compiler,
"make",
"--docs",
# since we're changing cwd, we need to output the absolute path
# instead of a (potentially) relative one.
os.path.abspath(args.out),
]
logging.debug(f"running {command} in `{args.build_dir}`")
process = Popen(command, cwd=args.build_dir)
process.communicate()
return process.returncode
def run_make(args):
"""
Compile an Elm app to HTML or JavaScript.
Our basic approach here is to symlink all the source directories and the
Main.elm file into place and modify elm.json to look in those places. This
means that we can use the build directory as a working directory so we get
an isolated elm-stuff directory.
"""
logging.debug(f"reading `{args.elm_json}`")
with open(args.elm_json, "r") as fh:
elm_json = json.load(fh)
##########################################################################
# STEP 1: symlink entries in `source-directories` to the right locations #
##########################################################################
try:
original_source_directories = elm_json["source-directories"]
except KeyError:
logging.error(
f'`{args.elm_json}` did not have a "source-directories" entry. Is it a package?'
)
return 1
source_directory_replacements = dict(
(sd.src, sd.target) for sd in args.source_directory or []
)
# we're creating a mapping here so we can chain the Main file to be relative
# to the symlinks later.
new_source_directories = {}
for i, directory in enumerate(original_source_directories):
try:
replacement = source_directory_replacements[directory]
except KeyError:
logging.error(
f"I don't have a replacement path for `{directory}`. Please specify one with --source-directory {directory}=real-path-to-directory"
)
return 1
dest = f"src-{i}"
symlink_if_necessary(
os.path.abspath(replacement), os.path.join(args.build_dir, dest)
)
new_source_directories[replacement] = dest
logging.debug(f"new source directories: {new_source_directories}")
#############################################################
# STEP 2: Modify and write `elm.json` to the right location #
#############################################################
elm_json["source-directories"] = list(new_source_directories.values())
new_elm_json_path = os.path.join(args.build_dir, "elm.json")
logging.debug(f"writing `{new_elm_json_path}`")
# the compiler will do a full rebuild anytime the `elm.json` file changes.
# Writing only if necessary about halves the runtime for this script for
# noredink-ui's component catalog, with more expected savings the bigger the
# app gets.
write_if_necessary(
new_elm_json_path,
json.dumps(elm_json, indent=4),
)
##########################################################
# STEP 3: Make sure we're poining at the right main file #
##########################################################
# TODO: this is not necessarily going to work if the name is not `Main`
# because of the module declaration not matching the file name.
main = args.main
replaced = False
logging.debug(f"original main: {main}")
for original, replacement in new_source_directories.items():
if main.startswith(original):
main = os.path.join(replacement, main[len(original) + 1 :])
logging.debug(f"using `{main}` instead of `{args.main}`")
replaced = True
break
if not replaced:
# it's fine to build a main file outside a source directory, but let's
# take the absolute path so we can make sure to get it.
main = os.path.abspath(main)
#####################################################
# STEP 4: Prepare and run the `elm make` invocation #
#####################################################
command = [args.elm_compiler, "make", main, "--output", os.path.abspath(args.out)]
if args.debug and args.optimize:
print("Only one of --debug or --optimize can be set.")
return 1
if args.debug:
command.append("--debug")
if args.optimize:
command.append("--optimize")
logging.debug(f"running {command} in `{args.build_dir}`")
process = Popen(command, cwd=args.build_dir)
process.communicate()
return process.returncode
class SourceDirectory:
def __init__(self, s):
try:
src, target = s.split("=")
self.src = src
self.target = target
except:
raise ArgumentTypeError("A pair must be `a=b`")
if __name__ == "__main__":
parser = ArgumentParser(description=__doc__)
parser.add_argument("elm_json", help="Location of the elm.json")
parser.add_argument(
"--build-dir",
help="Where to perform the build. Should be an empty directory, or one you've already run a build in.",
)
parser.add_argument(
"--elm-compiler", help="path to the Elm compiler", default="elm"
)
parser.add_argument(
"--verbose", help="Turn on verbose logging", action="store_true"
)
subparsers = parser.add_subparsers(required=True)
docs = subparsers.add_parser("docs", help=run_docs.__doc__)
docs.set_defaults(func=run_docs)
docs.add_argument(
"--out", help="Path for the resulting docs JSON file", default="docs.json"
)
docs.add_argument("--src", help="Path to library source", default="src")
make = subparsers.add_parser("make", help=run_make.__doc__)
make.set_defaults(func=run_make)
make.add_argument(
"--out", help="Path for the resulting JavaScript or HTML file", default="app.js"
)
make.add_argument("--main", help="Main.elm file to build", default="Main.elm")
make.add_argument(
"--source-directory",
type=SourceDirectory,
metavar="SRC=TARGET",
action="append",
)
make.add_argument("--debug", action="store_true", help="Build in debug mode")
make.add_argument("--optimize", action="store_true", help="Build in optimize mode")
args = parser.parse_args()
# logging
logger = logging.getLogger()
handler = logging.StreamHandler(sys.stderr)
formatter = logging.Formatter("%(asctime)s [%(levelname)s] %(message)s")
handler.setFormatter(formatter)
logger.addHandler(handler)
logger.setLevel(logging.DEBUG if args.verbose else logging.WARNING)
# run!
if args.build_dir is None:
with tempfile.TemporaryDirectory() as temp:
logging.warning(
f"building in temporary directory `{temp}`, which will be cleaned up after the build. If you want a persistent place to build, pass it in with `--build-dir`!"
)
args.build_dir = temp
sys.exit(args.func(args))
else:
sys.exit(args.func(args))
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment