diff --git a/examples/Racers offline.yaml b/examples/Racers offline.yaml new file mode 100644 index 0000000..e5de0f2 --- /dev/null +++ b/examples/Racers offline.yaml @@ -0,0 +1,58 @@ +# Example configuration file for MakeCode Arcade to App to compile to static +# HTML, CSS, and JS files + +# Config version (single number, incremented if breaking change to schema is +# made) +version: 1 + +project: + name: Racers + path_friendly_name: racers + description: "Enjoy the high-speed thrills of car racing in MakeCode Arcade! + For the MakeCode Arcade Mini Game Jam #3." + author: Cyrus Yiu + version: 1.3.2 + # This is what the window title will be + # This will also be used for the executable file name for Electron outputs + # You can use {NAME} or {VERSION} or {AUTHOR} to substitute the correct + # values + title: "{NAME} v{VERSION}" + +inputs: + # Can be share_link, github, or path + code: + type: path + value: "E:/Racers" + + assets: + # Icon is optional but highly recommended, it will be used as the favicon + # and app icon + icon: + type: path + value: "E:/Racers/Racers icon.png" + +# Path where the build process will take place +build_dir: "./examples/Racers-offline-build" + +# The MakeCode Arcade version to target +# Must be explicit 3 number sem ver, do not add a "v" at the beginning +# To find it, go to https://arcade.makecode.com/ and click on the settings +# cogwheel button, click About..., and see "arcade version: x.y.z" +# As of the time of writing this, the latest version is 4.0.14 +target: 2.0.48 + +# You can have multiple outputs +# Currently available options are static, static-singlefile, electron, and +# tauri +outputs: + - type: static + - type: static-singlefile +# - type: electron +# window: +# width: 640 +# height: 480 +# - type: tauri +# identifier: com.unsignedarduino.racers +# window: +# width: 640 +# height: 480 diff --git a/examples/Racers.yaml b/examples/Racers.yaml index 841ee2d..210e28f 100644 --- a/examples/Racers.yaml +++ b/examples/Racers.yaml @@ -48,6 +48,13 @@ inputs: # Path where the build process will take place build_dir: "./examples/Racers-build" +# The MakeCode Arcade version to target +# Must be explicit 3 number sem ver, do not add a "v" at the beginning +# To find it, go to https://arcade.makecode.com/ and click on the settings +# cogwheel button, click About..., and see "arcade version: x.y.z" +# As of the time of writing this, the latest version is 4.0.14 +target: 4.0.14 + # You can have multiple outputs # Currently available options are static, static-singlefile, electron, and # tauri diff --git a/pyproject.toml b/pyproject.toml index eea8454..3783e5d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,7 @@ dependencies = [ "requests", "beautifulsoup4", "pillow", + "platformdirs>=4.11.0", ] [project.scripts] diff --git a/src/mkcd2app/build_project/__init__.py b/src/mkcd2app/build_project/__init__.py index 84c0c52..cf0a18c 100644 --- a/src/mkcd2app/build_project/__init__.py +++ b/src/mkcd2app/build_project/__init__.py @@ -1,5 +1,4 @@ import logging -import shutil from contextlib import ExitStack from dataclasses import dataclass from pathlib import Path @@ -9,7 +8,7 @@ from mkcd2app.build_project.inputs.code import ( build_binary_js, - download_and_mod_supporting_files, + copy_support_files, fetch_code, ) from mkcd2app.build_project.website import ( @@ -19,42 +18,13 @@ install_deps_and_build_website_singlefile, ) from mkcd2app.config import load_config_from_yaml -from mkcd2app.config.model import StaticOutput, StaticSinglefileOutput +from mkcd2app.models.config import StaticOutput, StaticSinglefileOutput from mkcd2app.utils.logger import create_logger -from mkcd2app.utils.resources import get_js_tools_path, get_template_path -from mkcd2app.utils.run import run_cmd +from mkcd2app.utils.resources import get_resource_template_path logger = create_logger(name=__name__, level=logging.INFO) -@task(namespace="mkcd2app") -def install_mkcd_build_tools(config_yaml: str, js_tools_src: ContentDir) -> ContentDir: - """ - Installs the MakeCode Arcade build tools. - - :param config_yaml: The raw YAML text of the config file. - :param js_tools_src: A redun.ContentDir pointing to the js_tools directory, - so that redun tracks changes to package.json etc. - :return: A redun.ContentDir that points to node_modules, this is only used so that - redun will see that some tasks depend on `mkc` being installed. - """ - logger.info("Installing MakeCode Arcade build tools") - - config = load_config_from_yaml(config_yaml) - build_path = Path(config.build_dir) - logger.debug(f"Tools will be installed in {build_path}") - - js_tools_path = Path(js_tools_src.path) - shutil.copy(js_tools_path / "package.json", build_path / "package.json") - shutil.copy(js_tools_path / "package-lock.json", build_path / "package-lock.json") - - run_cmd(["npm", "ci"], cwd=build_path) - - logger.debug("All MakeCode Arcade build tools installed") - - return ContentDir(str(build_path / "node_modules")) - - @dataclass class BuildProjectResult: static: ContentDir | None = None @@ -79,22 +49,15 @@ def build_project(config_yaml: str) -> BuildProjectResult: build_dir.mkdir(parents=True, exist_ok=True) with ExitStack() as stack: - js_tools_path = stack.enter_context(get_js_tools_path()) - js_tools_content = ContentDir(str(js_tools_path)) - - template_path = stack.enter_context(get_template_path("vite-project")) - template_content = ContentDir(str(template_path)) + template_path = stack.enter_context(get_resource_template_path("vite-project")) + template_content = ContentDir(str(template_path / "vite-project")) - # Install `mkc` with `npm ci` in build dir - node_modules_for_mkc = install_mkcd_build_tools(config_yaml, js_tools_content) # Fetch game source code with `mkc`, `git`, or copy from disk - code_path = fetch_code(config_yaml, node_modules_for_mkc) + code_path = fetch_code(config_yaml) # Build binary.js with `mkc` bin_js_path = build_binary_js(config_yaml, code_path) - # Download supporting files to run binary.js, including ---simulator.html and all - # it's references, and get favicon.ico if present - support_path = download_and_mod_supporting_files(config_yaml) - + # Copy ---simulator.html from target dir and get favicon.ico if present + support_path = copy_support_files(config_yaml) # Copy website template (clean copy with template files only) website_path = copy_website_template(config_yaml, template_content) # Copy + fill (separate dir so stages don't mutate each other's @@ -104,7 +67,8 @@ def build_project(config_yaml: str) -> BuildProjectResult: ) results = BuildProjectResult() - + # Build the website outputs + # Electron and Tauri outputs depend on static_singlefile so redun figures it out logger.debug(f"{config.outputs=}") for output in config.outputs: match output.root: diff --git a/src/mkcd2app/build_project/inputs/code.py b/src/mkcd2app/build_project/inputs/code.py index 364cc82..29ac309 100644 --- a/src/mkcd2app/build_project/inputs/code.py +++ b/src/mkcd2app/build_project/inputs/code.py @@ -1,31 +1,32 @@ +import json import logging import shutil from io import BytesIO from pathlib import Path import requests -from bs4 import BeautifulSoup from PIL import Image from redun import task from redun.file import ContentDir, ContentFile from mkcd2app.config import load_config_from_yaml -from mkcd2app.config.model import ( +from mkcd2app.models.config import ( GitHubCodeSource, PathAssetSource, PathCodeSource, ShareLinkCodeSource, UrlAssetSource, ) +from mkcd2app.utils.filesystem import rmtree_robust from mkcd2app.utils.logger import create_logger -from mkcd2app.utils.paths import rmtree_robust +from mkcd2app.utils.paths import get_js_tools_bin_dir, get_sim_html_path from mkcd2app.utils.run import run_cmd logger = create_logger(name=__name__, level=logging.INFO) @task(namespace="mkcd2app") -def fetch_code(config_yaml: str, node_modules_for_mkc: ContentDir) -> ContentDir: +def fetch_code(config_yaml: str) -> ContentDir: """ Download/clone/copy the source code to the build directory. @@ -33,8 +34,6 @@ def fetch_code(config_yaml: str, node_modules_for_mkc: ContentDir) -> ContentDir Example output: ./racers-source :param config_yaml: The raw YAML text of the config file. - :param node_modules_for_mkc: redun.ContentDir that points to the node_modules - directory, this ensures that this task depends on `mkc` being installed. :return: A redun.ContentDir that points to the source code. """ config = load_config_from_yaml(config_yaml) @@ -48,10 +47,13 @@ def fetch_code(config_yaml: str, node_modules_for_mkc: ContentDir) -> ContentDir match config.inputs.code.root: case ShareLinkCodeSource(value=url): - logger.debug(f"Downloading source code from {url}") + logger.debug(f"Downloading source code from {url} with `mkc` CLI") code_path.mkdir(parents=True) - logger.debug(f"Using `mkc` from {node_modules_for_mkc}") - run_cmd(["npx", "mkc", "download", str(url)], cwd=code_path) + run_cmd( + ["mkc", "download", str(url)], + cwd=code_path, + which_path=get_js_tools_bin_dir(), + ) case GitHubCodeSource(value=url, checkout=checkout_target): logger.debug(f"Cloning source code from {url}@{checkout_target}") abs_code_path = code_path.resolve() @@ -72,6 +74,12 @@ def fetch_code(config_yaml: str, node_modules_for_mkc: ContentDir) -> ContentDir logger.debug(f"Copying source code from {path}") shutil.copytree(path, code_path) + mkc_json_path = code_path / "mkc.json" + logger.debug("Writing mkc.json") + version = config.target + mkc_json = {"targetWebsite": f"https://arcade.makecode.com/v{version}"} + mkc_json_path.write_text(json.dumps(mkc_json)) + logger.debug("Source code downloaded") return ContentDir(str(code_path)) @@ -94,7 +102,11 @@ def build_binary_js(config_yaml: str, code_path: ContentDir) -> ContentFile: cwd = Path(code_path.path) logger.debug(f"Building in cwd {cwd}") - run_cmd(["npx", "mkc", "build", "-j"], cwd=cwd) + run_cmd( + ["mkc", "build", "-j"], + cwd=cwd, + which_path=get_js_tools_bin_dir(), + ) bin_js_path = cwd / "built" / "binary.js" logger.debug(f"binary.js available at {bin_js_path}") @@ -112,7 +124,7 @@ def build_binary_js(config_yaml: str, code_path: ContentDir) -> ContentFile: @task(namespace="mkcd2app") -def download_and_mod_supporting_files(config_yaml: str) -> ContentDir: +def copy_support_files(config_yaml: str) -> ContentDir: """ Download and modify all supporting files needed to run binary.js for the website @@ -128,60 +140,29 @@ def download_and_mod_supporting_files(config_yaml: str) -> ContentDir: Path(config.build_dir) / f"{config.project.path_friendly_name}-binary-js-support" ) - logger.info(f"Downloading supporting files to {support_path}") + target = config.target + logger.info( + f"Copying ---simulator.html for MakeCode Arcade {target} to {support_path}" + ) # Clean previous output, not wasteful because redun handles caching if support_path.exists(): shutil.rmtree(support_path) support_path.mkdir(parents=True) - - logger.debug("Downloading main simulator file") - res = requests.get("https://trg-arcade.userpxt.io/---simulator") - res.raise_for_status() - sim_html = res.text - - logger.debug( - f"Analyzing sim HTML ({len(sim_html)} chars) for required CSS and JS files" - ) - soup = BeautifulSoup(sim_html, features="html.parser") - css_links = soup.find_all("link", rel="stylesheet") - js_scripts = soup.find_all("script") - logger.debug(f"Found {len(css_links)} CSS links and {len(js_scripts)} JS scripts") - for css in css_links: - url = css.get("href") - if url: - logger.debug(f"Downloading CSS file {url}") - res = requests.get(str(url)) - res.raise_for_status() - style_tag = soup.new_tag("style") - style_tag.string = res.text - css.replace_with(style_tag) - logger.debug(f"Inlined CSS from {url}") - for js in js_scripts: - url = js.get("src") - if url: - logger.debug(f"Downloading JS file {url}") - res = requests.get(str(url)) - res.raise_for_status() - js.string = res.text - del js["src"] - logger.debug(f"Inlined JS from {url}") - new_sim_html = soup.prettify(formatter="html5") - path = support_path / "---simulator.html" - path.write_text(new_sim_html) - logger.debug(f"Wrote modified simulator HTML to {path}") + # Only one file to copy + shutil.copy(get_sim_html_path(target), support_path) if config.inputs.assets.icon: match config.inputs.assets.icon.root: - case UrlAssetSource(value=url): # type: ignore[misc] - logger.debug(f"Downloading icon from {url}") - res = requests.get(str(url)) + case UrlAssetSource(value=icon_url): + logger.debug(f"Downloading icon from {icon_url}") + res = requests.get(str(icon_url)) res.raise_for_status() buffer = BytesIO(res.content) im = Image.open(buffer) - case PathAssetSource(value=path): # type: ignore[misc] - logger.debug(f"Opening icon from {path}") - im = Image.open(path) + case PathAssetSource(value=icon_path): + logger.debug(f"Opening icon from {icon_path}") + im = Image.open(icon_path) favicon_path = support_path / "favicon.ico" logger.debug(f"Saving favicon to {favicon_path}") im.save(favicon_path) diff --git a/src/mkcd2app/build_project/website.py b/src/mkcd2app/build_project/website.py index 126865c..98d4055 100644 --- a/src/mkcd2app/build_project/website.py +++ b/src/mkcd2app/build_project/website.py @@ -9,6 +9,7 @@ from mkcd2app.config import load_config_from_yaml from mkcd2app.utils.logger import create_logger +from mkcd2app.utils.paths import get_templates_npm_cache_dir from mkcd2app.utils.run import run_cmd logger = create_logger(name=__name__, level=logging.INFO) @@ -143,7 +144,10 @@ def install_deps_and_build_website(website_filled_path: ContentDir) -> ContentDi shutil.rmtree(dst) shutil.copytree(src, dst) - run_cmd(["npm", "ci"], cwd=dst) + cache_path = get_templates_npm_cache_dir() + logger.debug(f"Using template npm cache at {cache_path} to install") + + run_cmd(["npm", "ci", "--cache", str(cache_path), "--offline"], cwd=dst) logger.debug("Website dependencies installed") run_cmd(["npm", "run", "build"], cwd=dst) @@ -180,7 +184,10 @@ def install_deps_and_build_website_singlefile( shutil.rmtree(dst) shutil.copytree(src, dst) - run_cmd(["npm", "ci"], cwd=dst) + cache_path = get_templates_npm_cache_dir() + logger.debug(f"Using template npm cache at {cache_path} to install") + + run_cmd(["npm", "ci", "--cache", str(cache_path), "--offline"], cwd=dst) logger.debug("Website dependencies installed") run_cmd(["npm", "run", "build:singlefile"], cwd=dst) diff --git a/src/mkcd2app/cli.py b/src/mkcd2app/cli.py index 4ea7402..20a9cd0 100644 --- a/src/mkcd2app/cli.py +++ b/src/mkcd2app/cli.py @@ -13,16 +13,17 @@ def generate_and_parse_args() -> Namespace: :return: A `Namespace` object with parsed CLI arguments. """ parser = ArgumentParser( + prog="mkcd2app", description="Convert your MakeCode Arcade games into a " - "standalone offline executable!" + "standalone offline executable!", ) parser.add_argument( "--debug", action="store_true", - help="Enable debug logging. This must go first before the sub command.", + help="Enable debug logging. This must go first before any sub commands.", ) subparsers = parser.add_subparsers(required=True, dest="command") - # build subcommand + parser_build = subparsers.add_parser( "build", help="Build your MakeCode Arcade game." ) @@ -33,6 +34,46 @@ def generate_and_parse_args() -> Namespace: help="Delete the entire build directory before building.", ) + parser_toolchain = subparsers.add_parser( + "toolchain", help="Manage the MakeCode CLI toolchain." + ) + toolchain_subparsers = parser_toolchain.add_subparsers( + required=True, dest="toolchain_command" + ) + toolchain_subparsers.add_parser( + "install", + help="Install the MakeCode CLI toolchain for this specific mkcd2app version.", + ) + toolchain_subparsers.add_parser( + "status", help="Show the installed MakeCode CLI toolchain." + ) + toolchain_subparsers.add_parser( + "uninstall", + help="Uninstall the MakeCode CLI toolchain for this specific mkcd2app version.", + ) + + parser_target = subparsers.add_parser( + "target", help="Manage MakeCode Arcade target versions." + ) + target_subparsers = parser_target.add_subparsers( + required=True, dest="target_command" + ) + parser_target_install = target_subparsers.add_parser( + "install", help="Install a MakeCode Arcade target version." + ) + parser_target_install.add_argument( + "version", type=str, help="Target version to install." + ) + target_subparsers.add_parser( + "list", help="List installed MakeCode Arcade target versions." + ) + parser_target_uninstall = target_subparsers.add_parser( + "uninstall", help="Uninstall a MakeCode Arcade target version." + ) + parser_target_uninstall.add_argument( + "version", type=str, help="Target version to uninstall." + ) + args = parser.parse_args() logger.debug(f"Received arguments: {args}") return args diff --git a/src/mkcd2app/config/__init__.py b/src/mkcd2app/config/__init__.py index 8f1d4c4..d5cbc40 100644 --- a/src/mkcd2app/config/__init__.py +++ b/src/mkcd2app/config/__init__.py @@ -2,7 +2,7 @@ import yaml -from mkcd2app.config.model import BuildConfig +from mkcd2app.models.config import BuildConfig from mkcd2app.utils.logger import create_logger logger = create_logger(name=__name__, level=logging.INFO) diff --git a/src/mkcd2app/main.py b/src/mkcd2app/main.py index ef7bbd2..524c508 100644 --- a/src/mkcd2app/main.py +++ b/src/mkcd2app/main.py @@ -2,14 +2,20 @@ import shutil from pathlib import Path -import redun -import redun.file -from redun import Scheduler - from mkcd2app.build_project import BuildProjectResult, build_project from mkcd2app.cli import generate_and_parse_args from mkcd2app.config import load_config_from_yaml +from mkcd2app.target.install import install_target +from mkcd2app.target.uninstall import uninstall_target +from mkcd2app.toolchain.install import install_toolchain +from mkcd2app.toolchain.uninstall import uninstall_toolchain from mkcd2app.utils.logger import create_logger, set_all_stdout_logger_levels +from mkcd2app.utils.paths import ( + get_redun_db_for_target_path, + get_redun_db_for_toolchain_path, +) +from mkcd2app.utils.run_redun_task import run_redun_task +from mkcd2app.utils.text import raise_for_invalid_strict_semver logger = create_logger(name=__name__, level=logging.INFO) @@ -21,11 +27,55 @@ def main() -> None: set_all_stdout_logger_levels(logging.DEBUG) logger.debug(f"Received arguments: {args}") - if args.command == "build": - logger.debug("Building project") + if args.command == "toolchain": + if args.toolchain_command == "install": + logger.debug("Installing MakeCode CLI toolchain") + run_redun_task( + expr=install_toolchain(), + redun_db_path=get_redun_db_for_toolchain_path(), + ) + logger.debug("Toolchain installed") + elif args.toolchain_command == "status": + logger.debug("Checking MakeCode CLI toolchain status") + + elif args.toolchain_command == "uninstall": + logger.debug("Uninstalling MakeCode CLI toolchain") + # noinspection none-function-assignment + run_redun_task( + expr=uninstall_toolchain(), + redun_db_path=get_redun_db_for_toolchain_path(), + ) + logger.debug("Toolchain uninstalled") + elif args.command == "target": + if args.target_command == "install": + install_version: str = args.version + raise_for_invalid_strict_semver(install_version) + logger.debug(f"Installing MakeCode CLI target version {install_version}") + run_redun_task( + expr=install_target(install_version), + redun_db_path=get_redun_db_for_target_path(), + ) + logger.debug(f"Target {install_version} installed") + elif args.target_command == "list": + logger.debug("Listing MakeCode CLI target") + elif args.target_command == "uninstall": + uninstall_version: str = args.version + raise_for_invalid_strict_semver(uninstall_version) + logger.debug( + f"Uninstalling MakeCode CLI target version {uninstall_version}" + ) + # noinspection none-function-assignment + run_redun_task( + expr=uninstall_target(uninstall_version), + redun_db_path=get_redun_db_for_target_path(), + ) + logger.debug(f"Target {uninstall_version} uninstalled") + + elif args.command == "build": config_path = Path(args.config) - logger.debug(f"Loading config from {config_path}") + logger.debug(f"Building project with config {config_path}") + config_text = config_path.read_text() # Parse once only to extract build_dir for the redun DB path. @@ -42,24 +92,10 @@ def main() -> None: else: logger.debug("Build directory does not exist; nothing to clear") - build_dir.mkdir(parents=True, exist_ok=True) - db_uri = f"sqlite:///{build_dir.resolve() / '.redun-cache.db'}" - logger.debug(f"redun cache DB: {db_uri}") - # noinspection PyUnresolvedReferences - redun_config = redun.config.Config( - { - "scheduler": {"log_level": "DEBUG"}, - "backend": {"db_uri": db_uri}, - } - ) - scheduler = Scheduler(config=redun_config) - # Load/migrate the backend so the persistent DB is properly set up. - # Without this, providing a custom db_uri skips the automatic - # engine creation and migration that the in-memory default does. - scheduler.load() - results: BuildProjectResult = scheduler.run( - build_project(config_text), + results: BuildProjectResult = run_redun_task( + build_project(config_text), build_dir.resolve() / ".redun-cache.db" ) + if results.static: logger.info(f"Static website directory is at {results.static.path}") if results.static_singlefile: diff --git a/src/mkcd2app/models/__init__.py b/src/mkcd2app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/mkcd2app/config/model.py b/src/mkcd2app/models/config.py similarity index 98% rename from src/mkcd2app/config/model.py rename to src/mkcd2app/models/config.py index 6e13453..d08a245 100644 --- a/src/mkcd2app/config/model.py +++ b/src/mkcd2app/models/config.py @@ -109,6 +109,7 @@ class BuildConfig(BaseModel): project: Project inputs: Inputs build_dir: str = Field(..., alias="build_dir") + target: str = Field(..., alias="target") outputs: list[OutputOption] class Config: diff --git a/src/mkcd2app/models/metadata.py b/src/mkcd2app/models/metadata.py new file mode 100644 index 0000000..85c4d97 --- /dev/null +++ b/src/mkcd2app/models/metadata.py @@ -0,0 +1,19 @@ +from pydantic import BaseModel, Field, HttpUrl + + +# { +# "simUrl":"https://trg-arcade.userpxt.io/---simulator", +# "cdnUrl":"https://cdn.makecode.com", +# "version":"v0.0.0", +# "target":"arcade", +# "targetVersion":"4.0.14" +# } +class BinaryJSMetadata(BaseModel): + simUrl: HttpUrl = Field(..., description="Simulator URL") + cdnUrl: HttpUrl = Field(..., description="CDN base URL") + version: str = Field(..., description="Package version") + target: str = Field(..., description="Build target name") + targetVersion: str = Field(..., description="Target version string") + + class Config: + extra = "forbid" diff --git a/src/mkcd2app/resources/empty_project/README.md b/src/mkcd2app/resources/empty_project/README.md new file mode 100644 index 0000000..0519ecb --- /dev/null +++ b/src/mkcd2app/resources/empty_project/README.md @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/mkcd2app/resources/empty_project/assets.json b/src/mkcd2app/resources/empty_project/assets.json new file mode 100644 index 0000000..e69de29 diff --git a/src/mkcd2app/resources/empty_project/main.ts b/src/mkcd2app/resources/empty_project/main.ts new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/mkcd2app/resources/empty_project/main.ts @@ -0,0 +1 @@ + diff --git a/src/mkcd2app/resources/empty_project/pxt.json b/src/mkcd2app/resources/empty_project/pxt.json new file mode 100644 index 0000000..782e85f --- /dev/null +++ b/src/mkcd2app/resources/empty_project/pxt.json @@ -0,0 +1,12 @@ +{ + "name": "empty_project", + "description": "", + "dependencies": { + "device": "*" + }, + "files": [ + "main.ts", + "README.md", + "assets.json" + ] +} diff --git a/src/mkcd2app/js_tools/package-lock.json b/src/mkcd2app/resources/js_tools/package-lock.json similarity index 100% rename from src/mkcd2app/js_tools/package-lock.json rename to src/mkcd2app/resources/js_tools/package-lock.json diff --git a/src/mkcd2app/js_tools/package.json b/src/mkcd2app/resources/js_tools/package.json similarity index 100% rename from src/mkcd2app/js_tools/package.json rename to src/mkcd2app/resources/js_tools/package.json diff --git a/src/mkcd2app/templates/vite-project/.gitignore b/src/mkcd2app/resources/templates/vite-project/.gitignore similarity index 100% rename from src/mkcd2app/templates/vite-project/.gitignore rename to src/mkcd2app/resources/templates/vite-project/.gitignore diff --git a/src/mkcd2app/templates/vite-project/.prettierignore b/src/mkcd2app/resources/templates/vite-project/.prettierignore similarity index 100% rename from src/mkcd2app/templates/vite-project/.prettierignore rename to src/mkcd2app/resources/templates/vite-project/.prettierignore diff --git a/src/mkcd2app/templates/vite-project/README.md b/src/mkcd2app/resources/templates/vite-project/README.md similarity index 100% rename from src/mkcd2app/templates/vite-project/README.md rename to src/mkcd2app/resources/templates/vite-project/README.md diff --git a/src/mkcd2app/templates/vite-project/eslint.config.js b/src/mkcd2app/resources/templates/vite-project/eslint.config.js similarity index 100% rename from src/mkcd2app/templates/vite-project/eslint.config.js rename to src/mkcd2app/resources/templates/vite-project/eslint.config.js diff --git a/src/mkcd2app/templates/vite-project/index.html b/src/mkcd2app/resources/templates/vite-project/index.html similarity index 100% rename from src/mkcd2app/templates/vite-project/index.html rename to src/mkcd2app/resources/templates/vite-project/index.html diff --git a/src/mkcd2app/templates/vite-project/package-lock.json b/src/mkcd2app/resources/templates/vite-project/package-lock.json similarity index 100% rename from src/mkcd2app/templates/vite-project/package-lock.json rename to src/mkcd2app/resources/templates/vite-project/package-lock.json diff --git a/src/mkcd2app/templates/vite-project/package.json b/src/mkcd2app/resources/templates/vite-project/package.json similarity index 93% rename from src/mkcd2app/templates/vite-project/package.json rename to src/mkcd2app/resources/templates/vite-project/package.json index 63636b0..47898f3 100644 --- a/src/mkcd2app/templates/vite-project/package.json +++ b/src/mkcd2app/resources/templates/vite-project/package.json @@ -7,8 +7,8 @@ "dev": "vite", "lint": "eslint ", "writeLint": "eslint --fix ", - "format": "prettier --check .", - "writeFormat": "prettier --write .", + "format": "prettier --check ", + "writeFormat": "prettier --write ", "preview": "vite preview", "build": "tsc -b && vite build", "build:singlefile": "tsc -b && vite build --mode singlefile" diff --git a/src/mkcd2app/templates/vite-project/src/App.css b/src/mkcd2app/resources/templates/vite-project/src/App.css similarity index 100% rename from src/mkcd2app/templates/vite-project/src/App.css rename to src/mkcd2app/resources/templates/vite-project/src/App.css diff --git a/src/mkcd2app/templates/vite-project/src/App.tsx b/src/mkcd2app/resources/templates/vite-project/src/App.tsx similarity index 100% rename from src/mkcd2app/templates/vite-project/src/App.tsx rename to src/mkcd2app/resources/templates/vite-project/src/App.tsx diff --git a/src/mkcd2app/templates/vite-project/src/assets/---simulator.html b/src/mkcd2app/resources/templates/vite-project/src/assets/---simulator.html similarity index 100% rename from src/mkcd2app/templates/vite-project/src/assets/---simulator.html rename to src/mkcd2app/resources/templates/vite-project/src/assets/---simulator.html diff --git a/src/mkcd2app/templates/vite-project/src/assets/binary.js b/src/mkcd2app/resources/templates/vite-project/src/assets/binary.js similarity index 100% rename from src/mkcd2app/templates/vite-project/src/assets/binary.js rename to src/mkcd2app/resources/templates/vite-project/src/assets/binary.js diff --git a/src/mkcd2app/templates/vite-project/src/gameConfiguration.ts b/src/mkcd2app/resources/templates/vite-project/src/gameConfiguration.ts similarity index 100% rename from src/mkcd2app/templates/vite-project/src/gameConfiguration.ts rename to src/mkcd2app/resources/templates/vite-project/src/gameConfiguration.ts diff --git a/src/mkcd2app/templates/vite-project/src/global.d.ts b/src/mkcd2app/resources/templates/vite-project/src/global.d.ts similarity index 100% rename from src/mkcd2app/templates/vite-project/src/global.d.ts rename to src/mkcd2app/resources/templates/vite-project/src/global.d.ts diff --git a/src/mkcd2app/templates/vite-project/src/main.tsx b/src/mkcd2app/resources/templates/vite-project/src/main.tsx similarity index 100% rename from src/mkcd2app/templates/vite-project/src/main.tsx rename to src/mkcd2app/resources/templates/vite-project/src/main.tsx diff --git a/src/mkcd2app/templates/vite-project/src/utils/position.ts b/src/mkcd2app/resources/templates/vite-project/src/utils/position.ts similarity index 100% rename from src/mkcd2app/templates/vite-project/src/utils/position.ts rename to src/mkcd2app/resources/templates/vite-project/src/utils/position.ts diff --git a/src/mkcd2app/templates/vite-project/src/utils/toasts.ts b/src/mkcd2app/resources/templates/vite-project/src/utils/toasts.ts similarity index 100% rename from src/mkcd2app/templates/vite-project/src/utils/toasts.ts rename to src/mkcd2app/resources/templates/vite-project/src/utils/toasts.ts diff --git a/src/mkcd2app/templates/vite-project/tsconfig.app.json b/src/mkcd2app/resources/templates/vite-project/tsconfig.app.json similarity index 85% rename from src/mkcd2app/templates/vite-project/tsconfig.app.json rename to src/mkcd2app/resources/templates/vite-project/tsconfig.app.json index e30ef7d..3abe7e5 100644 --- a/src/mkcd2app/templates/vite-project/tsconfig.app.json +++ b/src/mkcd2app/resources/templates/vite-project/tsconfig.app.json @@ -2,9 +2,14 @@ "compilerOptions": { "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", "target": "es2023", - "lib": ["ES2023", "DOM"], + "lib": [ + "ES2023", + "DOM" + ], "module": "esnext", - "types": ["vite/client"], + "types": [ + "vite/client" + ], "allowArbitraryExtensions": true, "skipLibCheck": true, /* Bundler mode */ @@ -20,5 +25,7 @@ "erasableSyntaxOnly": false, "noFallthroughCasesInSwitch": true }, - "include": ["src"] + "include": [ + "src" + ] } diff --git a/src/mkcd2app/templates/vite-project/tsconfig.json b/src/mkcd2app/resources/templates/vite-project/tsconfig.json similarity index 100% rename from src/mkcd2app/templates/vite-project/tsconfig.json rename to src/mkcd2app/resources/templates/vite-project/tsconfig.json diff --git a/src/mkcd2app/templates/vite-project/tsconfig.node.json b/src/mkcd2app/resources/templates/vite-project/tsconfig.node.json similarity index 81% rename from src/mkcd2app/templates/vite-project/tsconfig.node.json rename to src/mkcd2app/resources/templates/vite-project/tsconfig.node.json index 1f8a29b..3bf20da 100644 --- a/src/mkcd2app/templates/vite-project/tsconfig.node.json +++ b/src/mkcd2app/resources/templates/vite-project/tsconfig.node.json @@ -2,8 +2,12 @@ "compilerOptions": { "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", "target": "es2023", - "lib": ["ES2023"], - "types": ["node"], + "lib": [ + "ES2023" + ], + "types": [ + "node" + ], "skipLibCheck": true, /* Bundler mode */ "module": "nodenext", @@ -17,5 +21,7 @@ "erasableSyntaxOnly": true, "noFallthroughCasesInSwitch": true }, - "include": ["vite.config.ts"] + "include": [ + "vite.config.ts" + ] } diff --git a/src/mkcd2app/templates/vite-project/vite.config.ts b/src/mkcd2app/resources/templates/vite-project/vite.config.ts similarity index 100% rename from src/mkcd2app/templates/vite-project/vite.config.ts rename to src/mkcd2app/resources/templates/vite-project/vite.config.ts diff --git a/src/mkcd2app/target/__init__.py b/src/mkcd2app/target/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/mkcd2app/target/install.py b/src/mkcd2app/target/install.py new file mode 100644 index 0000000..df40b40 --- /dev/null +++ b/src/mkcd2app/target/install.py @@ -0,0 +1,148 @@ +import json +import logging +import shutil +from contextlib import ExitStack +from pathlib import Path +from tempfile import TemporaryDirectory + +import requests +from bs4 import BeautifulSoup +from redun import task +from redun.file import ContentDir, ContentFile + +from mkcd2app.models.metadata import BinaryJSMetadata +from mkcd2app.utils.logger import create_logger +from mkcd2app.utils.paths import ( + get_js_tools_bin_dir, + get_sim_html_path, +) +from mkcd2app.utils.resources import get_resource_empty_project_path +from mkcd2app.utils.run import run_cmd +from mkcd2app.utils.text import extract_meta_comment + +logger = create_logger(name=__name__, level=logging.INFO) + + +@task(namespace="mkcd2app") +def warm_mkc_cache_for_version_and_get_binary_js_metadata( + empty_prj: ContentDir, version: str +) -> str: + """ + Warm the mkc compiler cache by building an empty project for the target MakeCode + Arcade version in a temporary directory. + + :param empty_prj: The ContentDir pointing to the directory containing the empty + MakeCode Arcade project. + :param version: The MakeCode Arcade version to target. E.g., "4.0.14". Must be + explicit 3 num sem ver, not just like "4.0" or "4", and do not include a "v". + :return: A BinaryJSMetadata model dumped to a JSON string. + """ + logger.info(f"Warming mkc compiler cache for MakeCode Arcade version {version}") + + empty_prj_path = Path(empty_prj.path) + + with TemporaryDirectory() as tmp_dir: + tmp_dir_path = Path(tmp_dir) + + logger.debug(f"Copying {empty_prj_path} to {tmp_dir_path}") + shutil.copytree(empty_prj_path, tmp_dir_path, dirs_exist_ok=True) + + mkc_json_path = tmp_dir_path / "mkc.json" + logger.debug("Writing mkc.json") + mkc_json = {"targetWebsite": f"https://arcade.makecode.com/v{version}"} + mkc_json_path.write_text(json.dumps(mkc_json)) + + logger.debug("Running build to warm cache") + run_cmd( + ["mkc", "build", "-j"], cwd=tmp_dir_path, which_path=get_js_tools_bin_dir() + ) + + binary_js_path = tmp_dir_path / "built" / "binary.js" + logger.debug(f"binary.js available at {binary_js_path}") + + logger.debug("Finished mkc cache warming, extracting binary.js metadata") + metadata = extract_meta_comment(binary_js_path) + logger.debug(f"{metadata=}") + return metadata.model_dump_json() + + +@task(namespace="mkcd2app") +def download_sim(bin_js_metadata: str, version: str) -> ContentFile: + """ + Download the simulator HTML and the supporting files needed to run binary.js for the + website. + + :param bin_js_metadata: The binary JS metadata JSON as a string. + :param version: The MakeCode Arcade version to target. E.g., "4.0.14". Must be + explicit 3 num sem ver, not just like "4.0" or "4", and do not include a "v". + :return: A ContentFile that points to the ---simulator.html. + """ + metadata = BinaryJSMetadata.model_validate_json(bin_js_metadata) + logger.info( + f"Downloading simulator for MakeCode Arcade version {metadata.targetVersion}" + ) + + logger.debug(f"Downloading main simulator file from {metadata.simUrl}") + res = requests.get(str(metadata.simUrl)) + res.raise_for_status() + sim_html = res.text + + logger.debug( + f"Analyzing sim HTML ({len(sim_html)} chars) for required CSS and JS files" + ) + soup = BeautifulSoup(sim_html, features="html.parser") + css_links = soup.find_all("link", rel="stylesheet") + js_scripts = soup.find_all("script") + logger.debug(f"Found {len(css_links)} CSS links and {len(js_scripts)} JS scripts") + for css in css_links: + url = css.get("href") + if url: + logger.debug(f"Downloading CSS file {url}") + res = requests.get(str(url)) + res.raise_for_status() + style_tag = soup.new_tag("style") + style_tag.string = res.text + css.replace_with(style_tag) + logger.debug(f"Inlined CSS from {url}") + for js in js_scripts: + url = js.get("src") + if url: + logger.debug(f"Downloading JS file {url}") + res = requests.get(str(url)) + res.raise_for_status() + js.string = res.text + del js["src"] + logger.debug(f"Inlined JS from {url}") + new_sim_html = soup.prettify(formatter="html5") + + path = get_sim_html_path(version) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(new_sim_html) + + logger.debug(f"Wrote simulator HTML to {path}") + + return ContentFile(str(path)) + + +@task(namespace="mkcd2app") +def install_target(version: str) -> ContentFile: + """ + Install a MakeCode Arcade target version for this mkcd2app version. + + :param version: The MakeCode Arcade version to target. E.g., "4.0.14". Must be + explicit 3 num sem ver, not just like "4.0" or "4", and do not include a "v". + :return: A ContentFile that points to the ---simulator.html for the installed version. + """ + logger.info(f"Installing MakeCode Arcade target version {version}") + + with ExitStack() as stack: + empty_prj_path = stack.enter_context(get_resource_empty_project_path()) + + # `mkc build -j` an empty project with the correct version + metadata = warm_mkc_cache_for_version_and_get_binary_js_metadata( + ContentDir(str(empty_prj_path)), version + ) + # Download ---simulator.html and supporting files, mod into single HTML file + sim_html = download_sim(metadata, version) + + return sim_html diff --git a/src/mkcd2app/target/uninstall.py b/src/mkcd2app/target/uninstall.py new file mode 100644 index 0000000..80c461c --- /dev/null +++ b/src/mkcd2app/target/uninstall.py @@ -0,0 +1,24 @@ +import logging + +from redun import task + +from mkcd2app.utils.filesystem import rmtree_robust +from mkcd2app.utils.logger import create_logger +from mkcd2app.utils.paths import get_target_version_dir + +logger = create_logger(name=__name__, level=logging.INFO) + + +@task(namespace="mkcd2app") +def uninstall_target(version: str) -> None: + """ + Uninstall a MakeCode Arcade target version for this mkcd2app version. + + :param version: The MakeCode Arcade version to target. E.g., "4.0.14". Must be + explicit 3 num sem ver, not just like "4.0" or "4", and do not include a "v". + """ + logger.info(f"Uninstall MakeCode Arcade target version {version}") + + target_path = get_target_version_dir(version) + logger.debug(f"Removing target directory {target_path}") + rmtree_robust(target_path) diff --git a/src/mkcd2app/toolchain/__init__.py b/src/mkcd2app/toolchain/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/mkcd2app/toolchain/install.py b/src/mkcd2app/toolchain/install.py new file mode 100644 index 0000000..b8b32b3 --- /dev/null +++ b/src/mkcd2app/toolchain/install.py @@ -0,0 +1,110 @@ +import logging +import shutil +from contextlib import ExitStack +from pathlib import Path +from tempfile import TemporaryDirectory + +from redun import task +from redun.file import ContentDir + +from mkcd2app.utils.logger import create_logger +from mkcd2app.utils.paths import ( + get_js_tools_dir, + get_templates_npm_cache_dir, +) +from mkcd2app.utils.resources import ( + get_resource_js_tools_path, + get_resource_template_path, +) +from mkcd2app.utils.run import run_cmd + +logger = create_logger(name=__name__, level=logging.INFO) + + +@task(namespace="mkcd2app") +def install_js_tools(js_tools: ContentDir) -> ContentDir: + """ + Installs necessary JS tools for mkcd2app. + + :param js_tools: The ContentDir pointing to the directory containing the + package.json and package-lock.json, which include the tools to install. + :return: A ContentDir pointing to node_modules, to ensure redun sees that this task + has something that depends on its results. + """ + logger.info("Installing JS tools") + + source_path = Path(js_tools.path) + dest_path = get_js_tools_dir() + dest_path.mkdir(parents=True, exist_ok=True) + + logger.debug(f"Copying package files from {source_path} to {dest_path}") + shutil.copy(source_path / "package.json", dest_path / "package.json") + shutil.copy(source_path / "package-lock.json", dest_path / "package-lock.json") + + logger.debug("`npm ci` to download") + run_cmd(["npm", "ci"], cwd=dest_path) + + logger.debug("All JS tools installed") + return ContentDir(str(dest_path / "node_modules")) + + +@task(namespace="mkcd2app") +def warm_npm_cache_for_templates(templates: ContentDir) -> ContentDir: + """ + For every template in the templates directory, copy them to a temporary directory, + run `npm ci --cache CACHE_DIR --prefer-online` where CACHE_DIR + + :param templates: The ContentDir pointing to the directory containing the templates. + :return: A ContentDir pointing to node_modules, to ensure redun sees that this task + has something that depends on its results. + """ + logger.info("Warming npm cache for templates") + + source_path = Path(templates.path) + cache_path = get_templates_npm_cache_dir() + cache_path.mkdir(parents=True, exist_ok=True) + + logger.debug(f"Looking for templates in {source_path}") + + all_templates = list(source_path.iterdir()) + logger.debug(f"Found {len(all_templates)} templates") + + for template in all_templates: + logger.debug(f"Caching packages for template {template.name}") + with TemporaryDirectory() as tmp_dir: + tmp_dir_path = Path(tmp_dir) + logger.debug(f"Copying {template} to {tmp_dir_path}") + shutil.copytree(template, tmp_dir_path, dirs_exist_ok=True) + + logger.debug("Caching npm packages") + run_cmd( + ["npm", "ci", "--cache", str(cache_path), "--prefer-online"], + cwd=tmp_dir_path, + ) + + logger.debug(f"npm cache filled at {cache_path}") + return ContentDir(str(cache_path)) + + +@task(namespace="mkcd2app") +def install_toolchain() -> tuple[ContentDir, ContentDir]: + """ + Install the toolchain for this mkcd2app version. + + :return: A tuple of two ContentDirs, where the first one is the node_modules folder + for the JS tools, and the other one is the cache directory for the templates. + """ + logger.info("Installing toolchain") + + with ExitStack() as stack: + js_tools_path = stack.enter_context(get_resource_js_tools_path()) + templates_path = stack.enter_context(get_resource_template_path()) + + # `npm ci` the necessary tools (`mkc` CLI itself) + js_tools_node_modules = install_js_tools(ContentDir(str(js_tools_path))) + # `npm ci --cache CACHE_DIR --prefer-online` for all templates + templates_npm_cache = warm_npm_cache_for_templates( + ContentDir(str(templates_path)) + ) + + return js_tools_node_modules, templates_npm_cache diff --git a/src/mkcd2app/toolchain/uninstall.py b/src/mkcd2app/toolchain/uninstall.py new file mode 100644 index 0000000..af77558 --- /dev/null +++ b/src/mkcd2app/toolchain/uninstall.py @@ -0,0 +1,22 @@ +import logging + +from redun import task + +from mkcd2app.utils.filesystem import rmtree_robust +from mkcd2app.utils.logger import create_logger +from mkcd2app.utils.paths import get_toolchain_dir + +logger = create_logger(name=__name__, level=logging.INFO) + + +@task(namespace="mkcd2app") +def uninstall_toolchain() -> None: + """ + Uninstall the toolchain for this mkcd2app version by removing the toolchain + directory and its redun cache DB. + """ + logger.info("Uninstalling toolchain") + + toolchain_path = get_toolchain_dir() + logger.debug(f"Removing toolchain directory {toolchain_path}") + rmtree_robust(toolchain_path) diff --git a/src/mkcd2app/utils/filesystem.py b/src/mkcd2app/utils/filesystem.py new file mode 100644 index 0000000..5a9d0b8 --- /dev/null +++ b/src/mkcd2app/utils/filesystem.py @@ -0,0 +1,25 @@ +import os +import shutil +import stat +from pathlib import Path +from types import TracebackType +from typing import Any + + +def _remove_readonly( + func: Any, + path: str, + exc_info: tuple[type[BaseException], BaseException, TracebackType], +) -> None: + """shutil.rmtree error handler: clear read-only bit and retry. + + Needed on Windows because git marks files under .git/objects (and + sometimes .git itself) read-only, which makes os.unlink/os.rmdir + raise PermissionError (WinError 5) even though we own the files. + """ + os.chmod(path, stat.S_IWRITE) + func(path) + + +def rmtree_robust(path: Path) -> None: + shutil.rmtree(path, onerror=_remove_readonly) diff --git a/src/mkcd2app/utils/paths.py b/src/mkcd2app/utils/paths.py index f0d6ab8..aabf229 100644 --- a/src/mkcd2app/utils/paths.py +++ b/src/mkcd2app/utils/paths.py @@ -1,27 +1,61 @@ -import os -import shutil -import stat +from importlib.metadata import version from pathlib import Path -from types import TracebackType -from typing import Any +from platformdirs import PlatformDirs -def _remove_readonly( - func: Any, - path: str, - exc_info: tuple[type[BaseException], BaseException, TracebackType], -) -> None: - """shutil.rmtree error handler: clear read-only bit and retry. - Needed on Windows because git marks files under .git/objects (and - sometimes .git itself) read-only, which makes os.unlink/os.rmdir - raise PermissionError (WinError 5) even though we own the files. - """ - os.chmod(path, stat.S_IWRITE) - func(path) +def get_mkcd2app_version() -> str: + return version("mkcd2app") -def rmtree_robust(path: Path) -> None: - if not path.exists(): - return - shutil.rmtree(path, onerror=_remove_readonly) +DIRS = PlatformDirs("mkcd2app", appauthor=False) + + +def get_user_data_dir() -> Path: + return Path(DIRS.user_data_dir) / get_mkcd2app_version() + + +def get_user_state_dir() -> Path: + return Path(DIRS.user_state_dir) / get_mkcd2app_version() + + +# Toolchain stuff + + +def get_toolchain_dir() -> Path: + return get_user_data_dir() / "toolchain" + + +def get_redun_db_for_toolchain_path() -> Path: + return get_user_state_dir() / "redun_db_for_toolchain.sqlite3" + + +def get_js_tools_dir() -> Path: + return get_toolchain_dir() / "js_tools" + + +def get_js_tools_bin_dir() -> Path: + return get_js_tools_dir() / "node_modules" / ".bin" + + +def get_templates_npm_cache_dir() -> Path: + return get_toolchain_dir() / "templates_npm_cache" + + +# Target stuff + + +def get_target_dir() -> Path: + return get_user_data_dir() / "target" + + +def get_redun_db_for_target_path() -> Path: + return get_user_state_dir() / "redun_db_for_target.sqlite3" + + +def get_target_version_dir(v: str) -> Path: + return get_target_dir() / v + + +def get_sim_html_path(v: str) -> Path: + return get_target_version_dir(v) / "---simulator.html" diff --git a/src/mkcd2app/utils/resources.py b/src/mkcd2app/utils/resources.py index 60efddb..aff6db0 100644 --- a/src/mkcd2app/utils/resources.py +++ b/src/mkcd2app/utils/resources.py @@ -5,14 +5,23 @@ @contextmanager -def get_template_path(name: str) -> Iterator[Path]: - ref = files("mkcd2app").joinpath("templates", name) +def get_resource_template_path(name: str | None = None) -> Iterator[Path]: + ref = files("mkcd2app").joinpath("resources", "templates") + if name: + ref.joinpath(name) with as_file(ref) as path: yield path @contextmanager -def get_js_tools_path() -> Iterator[Path]: - ref = files("mkcd2app").joinpath("js_tools") +def get_resource_js_tools_path() -> Iterator[Path]: + ref = files("mkcd2app").joinpath("resources", "js_tools") + with as_file(ref) as path: + yield path + + +@contextmanager +def get_resource_empty_project_path() -> Iterator[Path]: + ref = files("mkcd2app").joinpath("resources", "empty_project") with as_file(ref) as path: yield path diff --git a/src/mkcd2app/utils/run.py b/src/mkcd2app/utils/run.py index 08721cb..5bd4b5c 100644 --- a/src/mkcd2app/utils/run.py +++ b/src/mkcd2app/utils/run.py @@ -22,7 +22,11 @@ def __init__( super().__init__(f"{command} failed ({return_code}) in {cwd}") -def run_cmd(command: list[str], cwd: Path | str) -> str: +def run_cmd( + command: list[str], + cwd: Path | str, + which_path: str | os.PathLike[str] | None = None, +) -> str: """ Runs a command (as a list, no shell) and captures its output. @@ -32,11 +36,12 @@ def run_cmd(command: list[str], cwd: Path | str) -> str: :param command: The command as a list of arguments, e.g. ``["npm", "ci"]``. :param cwd: The working directory to execute the command in. + :param which_path: The path to pass to shutil.which, to search for the binary. :return: The stdout. :raises BuildError: If the command fails. :raises FileNotFoundError: If the executable cannot be found on PATH. """ - resolved = shutil.which(command[0]) + resolved = shutil.which(command[0], path=which_path) if resolved is None: raise FileNotFoundError( f"Executable '{command[0]}' not found on PATH. " diff --git a/src/mkcd2app/utils/run_redun_task.py b/src/mkcd2app/utils/run_redun_task.py new file mode 100644 index 0000000..af3b0c3 --- /dev/null +++ b/src/mkcd2app/utils/run_redun_task.py @@ -0,0 +1,27 @@ +import logging +from pathlib import Path + +import redun +from redun import Scheduler +from redun.expression import Expression, Result + +from mkcd2app.utils.logger import create_logger + +logger = create_logger(name=__name__, level=logging.INFO) + + +def run_redun_task(expr: Expression[Result] | Result, redun_db_path: Path) -> Result: + redun_db_path.parent.mkdir(parents=True, exist_ok=True) + db_uri = f"sqlite:///{redun_db_path.resolve()}" + logger.debug(f"redun cache DB: {db_uri}") + # noinspection PyUnresolvedReferences + redun_config = redun.config.Config( + { + "scheduler": {"log_level": "DEBUG"}, + "backend": {"db_uri": db_uri}, + } + ) + scheduler = Scheduler(config=redun_config) + scheduler.load() + # TODO: don't forget to remove cache=False after done testing + return scheduler.run(expr, cache=False) diff --git a/src/mkcd2app/utils/text.py b/src/mkcd2app/utils/text.py new file mode 100644 index 0000000..48f1a77 --- /dev/null +++ b/src/mkcd2app/utils/text.py @@ -0,0 +1,39 @@ +import re +from pathlib import Path + +from mkcd2app.models.metadata import BinaryJSMetadata + + +def extract_meta_comment(file_path: Path) -> BinaryJSMetadata: + """ + Extracts the // meta={} data from the binary.js file. + + :param file_path: Path to the binary.js + :return: The metadata (a BinaryJSMetadata) + :raises ValueError: If the meta comment is not found in file. + """ + with file_path.open("rt") as f: + for line in f: + line = line.strip() + if line.startswith("// meta="): + # Extract everything after '// meta=' + json_str = line.split("// meta=", 1)[1] + return BinaryJSMetadata.model_validate_json(json_str) + + raise ValueError("Meta comment not found in file.") + + +# Thanks Gemini +STRICT_NUMERIC_SEMVER = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$") + + +def is_strict_semver(version: str) -> bool: + """Validates string using official SemVer 2.0.0 regex pattern.""" + return bool(STRICT_NUMERIC_SEMVER.match(version)) + + +def raise_for_invalid_strict_semver(version: str) -> None: + if not is_strict_semver(version): + raise ValueError( + f"Version '{version}' is not a valid strict numeric semver (e.g., 1.2.3)." + ) diff --git a/uv.lock b/uv.lock index 60dbc3c..3212d6e 100644 --- a/uv.lock +++ b/uv.lock @@ -574,6 +574,7 @@ source = { editable = "." } dependencies = [ { name = "beautifulsoup4" }, { name = "pillow" }, + { name = "platformdirs" }, { name = "pydantic" }, { name = "pyyaml" }, { name = "redun" }, @@ -594,6 +595,7 @@ dev = [ requires-dist = [ { name = "beautifulsoup4" }, { name = "pillow" }, + { name = "platformdirs", specifier = ">=4.11.0" }, { name = "pydantic" }, { name = "pyyaml" }, { name = "redun", specifier = "==0.44.1" },