diff --git a/.github/actions/build-app/action.yml b/.github/actions/build-app/action.yml index d726113..82784b0 100644 --- a/.github/actions/build-app/action.yml +++ b/.github/actions/build-app/action.yml @@ -18,7 +18,8 @@ runs: path: . # The export reset prevents an error when building other targets # The default environment variable is set to "esp32" by the GitHub Action - command: 'export IDF_TARGET= && python tactility.py build -p Apps/${{ inputs.app_name }}' + # tt runs in the app's folder, so it uses the SDK version of this app's manifest + command: 'export IDF_TARGET= && cd Apps/${{ inputs.app_name }} && python ../../tt.py app build' - name: 'Upload Artifact' uses: actions/upload-artifact@v4 with: diff --git a/Apps/BusyBoxVi/manifest.properties b/Apps/BusyBoxVi/manifest.properties index d05436d..954b77a 100644 --- a/Apps/BusyBoxVi/manifest.properties +++ b/Apps/BusyBoxVi/manifest.properties @@ -2,13 +2,13 @@ manifest.version=0.3 target.sdk=0.8.0-dev target.platforms=esp32,esp32s3,esp32c6,esp32p4,posix-x86_64 id=tactility.busyboxvi -version.name=0.2.0 -version.code=2 -name=Vi +version.name=0.3.0 +version.code=3 +name=BusyBox vi description=BusyBox vi text editor app.0.id=tactility.busyboxvi app.0.name=BusyBox vi app.0.binary=vi -app.0.hidden=true +app.0.hidden=false app.0.headless=true app.0.stack.depth=4096 diff --git a/Apps/Neatvi/manifest.properties b/Apps/Neatvi/manifest.properties index d530d13..873c498 100644 --- a/Apps/Neatvi/manifest.properties +++ b/Apps/Neatvi/manifest.properties @@ -2,14 +2,14 @@ manifest.version=0.3 target.sdk=0.8.0-dev target.platforms=esp32,esp32s3,esp32c6,esp32p4,posix-x86_64 id=tactility.neatvi -version.name=0.3.0 -version.code=3 +version.name=0.4.0 +version.code=4 requires.ram=2M -name=Vi +name=Neatvi description=Neatvi text editor app.0.id=tactility.neatvi app.0.name=Neatvi app.0.binary=vi -app.0.hidden=true +app.0.hidden=false app.0.headless=true app.0.stack.depth=8192 diff --git a/Apps/Rcc700/manifest.properties b/Apps/Rcc700/manifest.properties index 7fef6b9..ec82b08 100644 --- a/Apps/Rcc700/manifest.properties +++ b/Apps/Rcc700/manifest.properties @@ -2,15 +2,15 @@ manifest.version=0.3 target.sdk=0.8.0-dev target.platforms=esp32,esp32s3,esp32c6,esp32p4,posix-x86_64 id=tactility.rcc700 -version.name=0.2.0 -version.code=2 +version.name=0.3.0 +version.code=3 requires.ram=2M name=rcc700 description=Mini C compiler for ESP32 RISC-V app.0.id=tactility.rcc700 app.0.name=rcc700 app.0.binary=rcc700 -app.0.hidden=true +app.0.hidden=false app.0.headless=true app.0.stack.depth=16384 app.0.cleanup=true diff --git a/Apps/Xcc700/manifest.properties b/Apps/Xcc700/manifest.properties index e2c98a1..2097d3e 100644 --- a/Apps/Xcc700/manifest.properties +++ b/Apps/Xcc700/manifest.properties @@ -2,14 +2,14 @@ manifest.version=0.3 target.sdk=0.8.0-dev target.platforms=esp32,esp32s3,esp32c6,esp32p4,posix-x86_64 id=tactility.xcc700 -version.name=0.2.0 -version.code=2 +version.name=0.3.0 +version.code=3 name=xcc700 description=Mini C compiler for ESP32 Xtensa app.0.id=tactility.xcc700 app.0.name=xcc700 app.0.binary=xcc700 -app.0.hidden=true +app.0.hidden=false app.0.headless=true app.0.stack.depth=16384 app.0.cleanup=true diff --git a/tactility.py b/tactility.py deleted file mode 100755 index 2f9bc5b..0000000 --- a/tactility.py +++ /dev/null @@ -1,1052 +0,0 @@ -#!/usr/bin/env python3 -# License: Apache License v2 - -import json -import os -import re -import shutil -import sys -import subprocess -import time -import urllib.request -import zipfile -import requests -import tarfile -from urllib.parse import urlparse - -ttbuild_path = ".tactility" -ttbuild_version = "7.0.1" -ttbuild_cdn = "https://cdn.tactilityproject.org" -ttbuild_sdk_json_validity = 3600 # seconds -ttport = 6666 -verbose = False -use_local_sdk = False -local_base_path = None -http_timeout_seconds = 10 -# App install uploads the whole package over HTTP and the device only responds once it's -# fully received, extracted and registered; large packages (e.g. bundled fonts/assets) can -# easily take well over http_timeout_seconds on a slow SD card, so give it a lot more room. -install_timeout_seconds = 120 - -shell_color_red = "\033[91m" -shell_color_orange = "\033[93m" -shell_color_green = "\033[32m" -shell_color_purple = "\033[35m" -shell_color_cyan = "\033[36m" -shell_color_reset = "\033[m" - -def print_help(): - print("Usage: python tactility.py [action] [options]") - print("") - print("Actions:") - print("") - print(" build Build the app. Optionally specify a platform.") - print(" Supported platforms are lower case. Example: esp32s3") - print(" Supported platforms are read from manifest.properties") - print(" Parameters:") - print(" (optional) --architecture [input], -a [input]") - print("") - print(" clean Clean the build folders") - print("") - print(" clearcache Clear the SDK cache") - print("") - print(" updateself Update this tool") - print("") - print(" run Run the application") - print(" Parameters:") - print(" (required) --host, -h [input]") - print("") - print(" install Install the application") - print(" Parameters:") - print(" (required) --host, -h [input]") - print("") - print(" uninstall Uninstall the application") - print(" Parameters:") - print(" (required) --host, -h [input]") - print("") - print(" bir Build, install then run.") - print(" Parameters:") - print(" (required) --host, -h [input]") - print(" (optional) --architecture, -a [input]") - print("") - print(" brrr Functionally the same as \"bir\", but \"app goes brrr\" meme variant.") - print(" Parameters:") - print(" (required) --host, -h [input]") - print(" (optional) --architecture, -a [input]") - print("") - print("Options:") - print(" -p, --path [input] Path to the app directory (defaults to current directory)") - print(" --help Show this commandline info") - print(" --local-sdk Use SDK specified by environment variable TACTILITY_SDK_PATH with platform subfolders matching target platforms.") - print(" --skip-build Run everything except the idf.py/CMake commands") - print(" --verbose Show extra console output") - print("") - print("Examples:") - print(" python tactility.py build") - print(" python tactility.py build --path Apps/Snake --architecture esp32s3 --verbose") - print(" python tactility.py bir --path Apps/Snake --host 192.168.1.50 --architecture esp32s3") - -# region Core - -def download_file(url, filepath): - global verbose - if verbose: - print(f"Downloading from {url} to {filepath}") - parsed = urlparse(url) - if parsed.scheme not in ("http", "https"): - print_error(f"Unsupported URL scheme: {parsed.scheme}") - return False - request = urllib.request.Request( - url, - data=None, - headers={ - "User-Agent": f"Tactility Build Tool {ttbuild_version}" - } - ) - try: - with urllib.request.urlopen(request, timeout=30) as response, open(filepath, mode="wb") as file: - file.write(response.read()) - return True - except OSError as error: - if verbose: - print_error(f"Failed to fetch URL {url}\n{error}") - return False - -def print_warning(message): - print(f"{shell_color_orange}WARNING: {message}{shell_color_reset}") - -def print_error(message): - print(f"{shell_color_red}ERROR: {message}{shell_color_reset}") - -def print_status_busy(status): - sys.stdout.write(f"⌛ {status}\r") - -def print_status_success(status): - # Trailing spaces are to overwrite previously written characters by a potentially shorter print_status_busy() text - print(f"✅ {shell_color_green}{status}{shell_color_reset} ") - -def print_status_error(status): - # Trailing spaces are to overwrite previously written characters by a potentially shorter print_status_busy() text - print(f"❌ {shell_color_red}{status}{shell_color_reset} ") - -def exit_with_error(message): - print_error(message) - sys.exit(1) - -def get_url(host, path): - return f"http://{host}:{ttport}{path}" - -def read_properties_file(path): - properties = {} - with open(path, "r") as file: - for line in file: - stripped = line.strip() - if not stripped or stripped.startswith("#"): - continue - key, sep, value = line.partition("=") - if not sep: - continue - properties[key.strip()] = value.strip() - return properties - -#endregion Core - -#region Versioning - -class SemanticVersion: - def __init__(self, major, minor, patch, tag=None): - self.major = major - self.minor = minor - self.patch = patch - self.tag = tag - - @staticmethod - def parse(version_string): - match = re.match(r"^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$", version_string) - if match is None: - exit_with_error(f"Invalid version format: {version_string}") - major, minor, patch, tag = match.groups() - return SemanticVersion(int(major), int(minor), int(patch), tag) - - def _numeric(self): - return (self.major, self.minor, self.patch) - - def __eq__(self, other): - return self._numeric() == other._numeric() and self.tag == other.tag - - def __lt__(self, other): - if self._numeric() != other._numeric(): - return self._numeric() < other._numeric() - # Same major.minor.patch: an untagged version outranks any tagged (pre-release) one. - if self.tag == other.tag: - return False - if self.tag is None: - return False - if other.tag is None: - return True - return self.tag < other.tag - - def __str__(self): - return f"{self.major}.{self.minor}.{self.patch}" + (f"-{self.tag}" if self.tag else "") - - # Ignores tags: a "-dev"/"-rc1"-tagged pre-release build of a version is still that version, - # not an older one, unlike __lt__'s full ordering (which ranks pre-releases below releases). - def is_older_release_of(self, other): - return self._numeric() < other._numeric() - -# Append a new entry whenever the tool drops support for older SDKs. -# The last entry is the currently effective minimum. -SDK_COMPATIBILITY_LEDGER = [ - SemanticVersion.parse("0.8.0"), -] - -def minimum_supported_sdk_version(): - return SDK_COMPATIBILITY_LEDGER[-1] - -def validate_sdk_compatibility(target_sdk_version): - minimum = minimum_supported_sdk_version() - if SemanticVersion.parse(target_sdk_version).is_older_release_of(minimum): - exit_with_error( - f"This tool requires SDK version {minimum} or newer " - f"(manifest.properties requests {target_sdk_version})" - ) - -#endregion Versioning - -#region Argument parsing - -# canonical name -> (short flag or None, long flag, takes_value) -ARG_SPECS = { - "path": ("-p", "--path", True), - "host": ("-h", "--host", True), - "platform": ("-a", "--architecture", True), - "verbose": (None, "--verbose", False), - "skip-build": (None, "--skip-build", False), - "local-sdk": (None, "--local-sdk", False), - "help": (None, "--help", False), -} - -class Arguments: - def __init__(self, values): - self.values = values - - def get(self, key, default=None): - return self.values.get(key, default) - - def has(self, key): - return key in self.values - -class ParsedCommand: - def __init__(self, action, args): - self.action = action - self.arguments = args - -def parse_command_line(argv): - token_to_spec = {} - for canonical, (short, long, takes_value) in ARG_SPECS.items(): - if short is not None: - token_to_spec[short] = (canonical, takes_value) - token_to_spec[long] = (canonical, takes_value) - - action = None - args = {} - index = 0 - while index < len(argv): - token = argv[index] - if token in token_to_spec: - canonical, takes_value = token_to_spec[token] - if takes_value: - index += 1 - if index >= len(argv): - exit_with_error(f"Missing value for {token}") - args[canonical] = argv[index] - else: - args[canonical] = "true" - elif action is None: - action = token - else: - exit_with_error(f"Unexpected argument: {token}") - index += 1 - return ParsedCommand(action, Arguments(args)) - -#endregion Argument parsing - -#region SDK helpers - -def get_tool_json_path(version): - return os.path.join(ttbuild_path, version, "tactility.py.json") - -def read_sdk_json(version): - json_file_path = get_tool_json_path(version) - with open(json_file_path) as json_file: - return json.load(json_file) - -def get_sdk_dir(version, platform): - global use_local_sdk, local_base_path - if use_local_sdk: - base_path = local_base_path - if base_path is None: - exit_with_error("TACTILITY_SDK_PATH environment variable is not set") - sdk_parent_dir = os.path.join(base_path, f"{version}-{platform}") - sdk_dir = os.path.join(sdk_parent_dir, "TactilitySDK") - if not os.path.isdir(sdk_dir): - exit_with_error(f"Local SDK folder not found for platform {platform}: {sdk_dir}") - return os.path.abspath(sdk_dir) - else: - # Must be absolute: this is exported as $TACTILITY_SDK_PATH and included by each app's - # main/CMakeLists.txt, which ESP-IDF also re-processes in a separate `cmake -P` subprocess - # (tools/cmake/scripts/component_get_requirements.cmake) with its own working directory - - # a relative path here resolves against whatever CWD that subprocess happens to have. - return os.path.abspath(os.path.join(ttbuild_path, f"{version}-{platform}", "TactilitySDK")) - -def validate_local_sdks(platforms, version): - if not use_local_sdk: - return - global local_base_path - base_path = local_base_path - for platform in platforms: - sdk_parent_dir = os.path.join(base_path, f"{version}-{platform}") - sdk_dir = os.path.join(sdk_parent_dir, "TactilitySDK") - if not os.path.isdir(sdk_dir): - exit_with_error(f"Local SDK folder missing for {platform}: {sdk_dir}") - if not sdk_has_sdkconfig(version, platform): - exit_with_error(f"Local SDK for {platform} does not contain sdkconfig.app.{platform}, which this tool requires. Rebuild it with the current SDK release scripts.") - -def get_sdk_root_dir(version, platform): - global ttbuild_cdn - return os.path.join(ttbuild_path, f"{version}-{platform}") - -def get_sdk_url(version, file): - global ttbuild_cdn - return f"{ttbuild_cdn}/sdk/{version}/{file}" - -def sdk_exists(version, platform): - sdk_dir = get_sdk_dir(version, platform) - return os.path.isdir(sdk_dir) - -def should_update_tool_json(version): - json_filepath = get_tool_json_path(version) - if os.path.exists(json_filepath): - json_modification_time = os.path.getmtime(json_filepath) - now = time.time() - global ttbuild_sdk_json_validity - minimum_seconds_difference = ttbuild_sdk_json_validity - return (now - json_modification_time) > minimum_seconds_difference - else: - return True - -def update_tool_json(version): - json_filepath = get_tool_json_path(version) - os.makedirs(os.path.dirname(json_filepath), exist_ok=True) - return download_file(get_sdk_url(version, "tactility.py.json"), json_filepath) - -def get_sdk_sdkconfig_path(version, platform): - return os.path.join(get_sdk_dir(version, platform), f"sdkconfig.app.{platform}") - -# ESP32 SDKs built before the sdkconfig was bundled lack it and can't build apps. POSIX SDKs never need one. -def sdk_has_sdkconfig(version, platform): - return platform.startswith("posix") or os.path.isfile(get_sdk_sdkconfig_path(version, platform)) - -def copy_sdk_sdkconfig(version, platform): - if platform.startswith("posix"): - return - sdkconfig_path = get_sdk_sdkconfig_path(version, platform) - if not os.path.isfile(sdkconfig_path): - exit_with_error(f"SDK does not contain {sdkconfig_path}") - shutil.copy(sdkconfig_path, "sdkconfig") - -#endregion SDK helpers - -#region Validation - -def validate_environment(platforms): - if any(not platform.startswith("posix") for platform in platforms) and os.environ.get("IDF_PATH") is None: - if sys.platform == "win32": - exit_with_error("Cannot find the Espressif IDF SDK. Ensure it is installed and that it is activated via %IDF_PATH%\\export.ps1") - else: - exit_with_error("Cannot find the Espressif IDF SDK. Ensure it is installed and that it is activated via $PATH_TO_IDF_SDK/export.sh") - if not os.path.exists("manifest.properties"): - exit_with_error("manifest.properties not found") - if use_local_sdk == False and os.environ.get("TACTILITY_SDK_PATH") is not None: - print_warning("TACTILITY_SDK_PATH is set, but will be ignored by this command.") - print_warning("If you want to use it, use the '--local-sdk' parameter") - elif use_local_sdk == True and os.environ.get("TACTILITY_SDK_PATH") is None: - exit_with_error("local build was requested, but TACTILITY_SDK_PATH environment variable is not set.") - -def validate_self(sdk_json): - if not "version" in sdk_json: - exit_with_error("Server returned invalid SDK data format (version not found)") - tool_version = sdk_json["version"] - if SemanticVersion.parse(ttbuild_version) < SemanticVersion.parse(tool_version): - print_warning(f"New version available: {tool_version} (currently using {ttbuild_version})") - print_warning(f"Run 'tactility.py updateself' to update.") - -#endregion Validation - -#region Manifest - -def read_manifest(): - return read_properties_file("manifest.properties") - -def is_v3_manifest(manifest): - return manifest.get("manifest.version") == "0.3" - -# The package identifier: a v2 manifest has no package/app distinction, so its single "app.id" -# doubles as both; a v3 manifest's package id is the bare "id" key, distinct from any of its -# (possibly several) "app.N.id" app ids. -def get_package_id(manifest): - return manifest["id"] if is_v3_manifest(manifest) else manifest["app.id"] - -# The id of the app "run"/"install" operate on. A v2 manifest has exactly one; a v3 manifest may -# declare several, so this picks the first ("app.0.id") as the one a single-app dev workflow means. -def get_primary_app_id(manifest): - return manifest["app.0.id"] if is_v3_manifest(manifest) else manifest["app.id"] - -def validate_manifest(manifest): - for key in ("manifest.version", "target.sdk", "target.platforms"): - if key not in manifest: - exit_with_error(f"Invalid manifest format: {key} not found") - if manifest["manifest.version"] not in ("0.2", "0.3"): - exit_with_error(f"Unsupported manifest.version: {manifest['manifest.version']}") - if is_v3_manifest(manifest): - for key in ("id", "version.name", "version.code"): - if key not in manifest: - exit_with_error(f"Invalid manifest format: {key} not found") - if "app.0.id" not in manifest: - exit_with_error("Invalid manifest format: app.0.id not found") - index = 0 - while f"app.{index}.id" in manifest: - for suffix in ("name", "binary"): - key = f"app.{index}.{suffix}" - if key not in manifest: - exit_with_error(f"Invalid manifest format: {key} not found") - index += 1 - else: - for key in ("app.id", "app.version.name", "app.version.code", "app.name"): - if key not in manifest: - exit_with_error(f"Invalid manifest format: {key} not found") - validate_sdk_compatibility(manifest["target.sdk"]) - -# Maps each binary this app builds to the directory its own CMake project lives in. A v2 -# manifest always describes exactly one app, built at the app's own root (see -# package_intermediate_binaries() for its fixed "app.{elf,so}" package filename, matching -# package_manifest_parse_v2()'s single implicit binary). A v3 manifest declaring a single -# "app.0.*" block is the same case: built at the app's own root. Only when a v3 manifest -# declares more than one "app.N.*" block (0-indexed, contiguous - the first missing "app.N.id" -# ends the list, mirroring package_manifest_parse_v3()) does each get its own subdirectory, -# named after its own "app.N.binary" (the filename it installs as - see -# package_manifest_parse_v3()'s doc). -def get_binary_dirs(manifest): - if is_v3_manifest(manifest): - binaries = [] - index = 0 - while f"app.{index}.id" in manifest: - binary = manifest[f"app.{index}.binary"] - binaries.append((binary, binary)) - index += 1 - if len(binaries) == 1: - return [(binaries[0][0], ".")] - for binary, directory in binaries: - if not os.path.isdir(directory): - exit_with_error(f"Binary directory not found for '{binary}': {directory}") - return binaries - else: - return [(manifest["app.id"], ".")] - -def is_valid_manifest_platform(manifest, platform): - manifest_platforms = manifest["target.platforms"].split(",") - return platform in manifest_platforms - -def validate_manifest_platform(manifest, platform): - if not is_valid_manifest_platform(manifest, platform): - exit_with_error(f"Platform {platform} is not available in the manifest.") - -def get_manifest_target_platforms(manifest, requested_platform): - if requested_platform == "" or requested_platform is None: - return manifest["target.platforms"].split(",") - else: - validate_manifest_platform(manifest, requested_platform) - return [requested_platform] - -#endregion Manifest - -#region SDK download - -def safe_extract_zip(zip_ref, target_dir): - target_dir = os.path.realpath(target_dir) - for member in zip_ref.infolist(): - dest = os.path.realpath(os.path.join(target_dir, member.filename)) - if not dest.startswith(target_dir + os.sep): - raise ValueError(f"Invalid zip entry: {member.filename}") - zip_ref.extractall(target_dir) - -def sdk_download(version, platform, sdk_root_dir=None): - if sdk_root_dir is None: - sdk_root_dir = get_sdk_root_dir(version, platform) - os.makedirs(sdk_root_dir, exist_ok=True) - sdk_index_url = get_sdk_url(version, "index.json") - print(f"Downloading SDK version {version} for {platform}") - sdk_index_filepath = os.path.join(sdk_root_dir, "index.json") - if verbose: - print(f"Downloading {sdk_index_url} to {sdk_index_filepath}") - if not download_file(sdk_index_url, sdk_index_filepath): - # TODO: 404 check, print a more accurate error - print_error(f"Failed to download SDK version {version}. Check your internet connection and make sure this release exists.") - return False - with open(sdk_index_filepath) as sdk_index_json_file: - sdk_index_json = json.load(sdk_index_json_file) - sdk_platforms = sdk_index_json["platforms"] - if platform not in sdk_platforms: - print_error(f"Platform {platform} not found in {sdk_platforms} for version {version}") - return False - sdk_platform_file = sdk_platforms[platform] - sdk_zip_source_url = get_sdk_url(version, sdk_platform_file) - sdk_zip_target_filepath = os.path.join(sdk_root_dir, f"{version}-{platform}.zip") - if verbose: - print(f"Downloading {sdk_zip_source_url} to {sdk_zip_target_filepath}") - if not download_file(sdk_zip_source_url, sdk_zip_target_filepath): - print_error(f"Failed to download {sdk_zip_source_url} to {sdk_zip_target_filepath}") - return False - with zipfile.ZipFile(sdk_zip_target_filepath, "r") as zip_ref: - safe_extract_zip(zip_ref, os.path.join(sdk_root_dir, "TactilitySDK")) - return True - -def sdk_download_all(version, platforms): - for platform in platforms: - if sdk_exists(version, platform) and not sdk_has_sdkconfig(version, platform): - print_warning(f"Cached SDK version {version} for {platform} is outdated (no sdkconfig.app.{platform}), downloading it again") - # Downloaded next to the cache, so a failed download keeps the cached SDK - sdk_root_dir = get_sdk_root_dir(version, platform) - replacement_root_dir = f"{sdk_root_dir}.new" - shutil.rmtree(replacement_root_dir, ignore_errors=True) - if not sdk_download(version, platform, replacement_root_dir): - shutil.rmtree(replacement_root_dir, ignore_errors=True) - return False - replacement_sdkconfig = os.path.join(replacement_root_dir, "TactilitySDK", f"sdkconfig.app.{platform}") - if not platform.startswith("posix") and not os.path.isfile(replacement_sdkconfig): - shutil.rmtree(replacement_root_dir, ignore_errors=True) - print_error(f"SDK version {version} for {platform} does not contain sdkconfig.app.{platform}, which this tool requires. Use a newer SDK version.") - return False - shutil.rmtree(sdk_root_dir) - shutil.move(replacement_root_dir, sdk_root_dir) - continue - if not sdk_exists(version, platform): - if not sdk_download(version, platform): - return False - if not sdk_has_sdkconfig(version, platform): - print_error(f"SDK version {version} for {platform} does not contain sdkconfig.app.{platform}, which this tool requires. Use a newer SDK version.") - return False - else: - if verbose: - print(f"Using cached download for SDK version {version} and platform {platform}") - return True - -#endregion SDK download - -#region CMakeLists scaffolding - -# Bump whenever CMAKELISTS_TEMPLATE below changes, so existing apps' generated CMakeLists.txt get -# regenerated on their next build instead of silently going stale. -CMAKELISTS_VERSION = 1 - -CMAKELISTS_TEMPLATE = """# tactility-cmakelists-version: %(version)d -cmake_minimum_required(VERSION 3.20) - -if (NOT DEFINED ENV{TACTILITY_SDK_PATH}) - message(FATAL_ERROR "TACTILITY_SDK_PATH environment variable is not set") -endif() - -get_filename_component(TACTILITY_SDK_PATH "$ENV{TACTILITY_SDK_PATH}" ABSOLUTE) -include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake") - -tactility_project_pre(%(app_id)s) -project(%(app_id)s) -tactility_project_post(%(app_id)s) -""" - -def cmakelists_version_marker(): - return f"# tactility-cmakelists-version: {CMAKELISTS_VERSION}" - -def ensure_cmakelists_up_to_date(project_id): - marker = cmakelists_version_marker() - if os.path.exists("CMakeLists.txt"): - with open("CMakeLists.txt", "r") as file: - first_line = file.readline().rstrip("\n") - if first_line == marker: - return - print(f"Updating CMakeLists.txt to {marker}") - content = CMAKELISTS_TEMPLATE % {"version": CMAKELISTS_VERSION, "app_id": project_id} - with open("CMakeLists.txt", "w") as file: - file.write(content) - -#endregion CMakeLists scaffolding - -#region Building - -def get_cmake_path(platform): - return os.path.join("build", f"cmake-build-{platform}") - -def find_elf_file(platform): - cmake_dir = get_cmake_path(platform) - if not os.path.exists(cmake_dir): - return None - # POSIX apps are dlopen()ed shared objects (app-posix-module), not idf.py/elf_loader - # relocatable images, so they land as a plain ".so" instead of "*.app.elf". - suffix = ".so" if platform.startswith("posix") else ".app.elf" - for file in os.listdir(cmake_dir): - if file.endswith(suffix): - return os.path.join(cmake_dir, file) - return None - -def get_posix_build_env(sdk_dir): - # A dev shell may already have ESP-IDF's export.sh sourced; strip it so the SDK's plain-CMake - # top-level CMakeLists.txt takes the POSIX branch instead of the idf.py one. - env = os.environ.copy() - env.pop("ESP_IDF_VERSION", None) - env.pop("IDF_PATH", None) - env["TACTILITY_SDK_PATH"] = sdk_dir - return env - -def build_posix(version, platform, skip_build): - sdk_dir = get_sdk_dir(version, platform) - if verbose: - print(f"Using SDK at {sdk_dir}") - if skip_build: - return True - env = get_posix_build_env(sdk_dir) - cmake_path = get_cmake_path(platform) - print_status_busy(f"Building {platform}") - configure_command = ["cmake", "-S", ".", "-B", cmake_path, "-G", "Ninja"] - if verbose: - print(f"Running command: {' '.join(configure_command)}") - configure_result = subprocess.run(configure_command, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) - if configure_result.returncode != 0: - print(configure_result.stdout.decode("UTF-8"), end="") - print_status_error(f"Configuring {platform}") - return False - build_command = ["cmake", "--build", cmake_path] - if verbose: - print(f"Running command: {' '.join(build_command)}") - with subprocess.Popen(build_command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=env) as process: - build_output = wait_for_process(process) - if process.returncode == 0: - print_status_success(f"Building {platform}") - return True - else: - for line in build_output: - print(line, end="") - print_status_error(f"Building {platform}") - return False - -def build_all(version, platforms, skip_build): - for platform in platforms: - if platform.startswith("posix"): - if not build_posix(version, platform, skip_build): - return False - # First build command must be "idf.py build", otherwise it fails to execute "idf.py elf" - # We check if the ELF file exists and run the correct command - # This can lead to code caching issues, so sometimes a clean build is required - elif find_elf_file(platform) is None: - if not build_first(version, platform, skip_build): - return False - else: - if not build_consecutively(version, platform, skip_build): - return False - return True - -def wait_for_process(process): - buffer = [] - if sys.platform != "win32": - os.set_blocking(process.stdout.fileno(), False) - while process.poll() is None: - while True: - line = process.stdout.readline() - if line: - decoded_line = line.decode("UTF-8") - if decoded_line != "": - buffer.append(decoded_line) - else: - break - else: - break - # Read any remaining output - for line in process.stdout: - decoded_line = line.decode("UTF-8") - if decoded_line: - buffer.append(decoded_line) - return buffer - -# The first build must call "idf.py build" and consecutive builds must call "idf.py elf" as it finishes faster. -# The problem is that the "idf.py build" always results in an error, even though the elf file is created. -# The solution is to suppress the error if we find that the elf file was created. -def build_first(version, platform, skip_build): - sdk_dir = get_sdk_dir(version, platform) - if verbose: - print(f"Using SDK at {sdk_dir}") - os.environ["TACTILITY_SDK_PATH"] = sdk_dir - copy_sdk_sdkconfig(version, platform) - elf_path = find_elf_file(platform) - # Remove previous elf file: re-creation of the file is used to measure if the build succeeded, - # as the actual build job will always fail due to technical issues with the elf cmake script - if elf_path is not None: - os.remove(elf_path) - if skip_build: - return True - print(f"Building first {platform} build") - cmake_path = get_cmake_path(platform) - print_status_busy(f"Building {platform} ELF") - shell_needed = sys.platform == "win32" - build_command = ["idf.py", "-B", cmake_path, "build"] - if verbose: - print(f"Running command: {' '.join(build_command)}") - with subprocess.Popen(build_command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, shell=shell_needed) as process: - build_output = wait_for_process(process) - # The return code is never expected to be 0 due to a bug in the elf cmake script, but we keep it just in case - if process.returncode == 0: - print(f"{shell_color_green}Building for {platform} ✅{shell_color_reset}") - return True - else: - if find_elf_file(platform) is None: - for line in build_output: - print(line, end="") - print_status_error(f"Building {platform} ELF") - return False - else: - print_status_success(f"Building {platform} ELF") - return True - -def build_consecutively(version, platform, skip_build): - sdk_dir = get_sdk_dir(version, platform) - if verbose: - print(f"Using SDK at {sdk_dir}") - os.environ["TACTILITY_SDK_PATH"] = sdk_dir - copy_sdk_sdkconfig(version, platform) - if skip_build: - return True - cmake_path = get_cmake_path(platform) - print_status_busy(f"Building {platform} ELF") - shell_needed = sys.platform == "win32" - build_command = ["idf.py", "-B", cmake_path, "elf"] - if verbose: - print(f"Running command: {' '.join(build_command)}") - with subprocess.Popen(build_command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, shell=shell_needed) as process: - build_output = wait_for_process(process) - if process.returncode == 0: - print_status_success(f"Building {platform} ELF") - return True - else: - for line in build_output: - print(line, end="") - print_status_error(f"Building {platform} ELF") - return False - -#endregion Building - -#region Packaging - -def package_intermediate_manifest(target_path): - if not os.path.isfile("manifest.properties"): - print_error("manifest.properties not found") - return False - shutil.copy("manifest.properties", os.path.join(target_path, "manifest.properties")) - return True - -def get_artifact_extension(platform): - # POSIX apps are dlopen()ed shared objects (app-posix-module), not idf.py/elf_loader - # relocatable images, so they land as a plain ".so" instead of ".elf". - return ".so" if platform.startswith("posix") else ".elf" - -def package_intermediate_binaries(target_path, platforms, manifest): - # Each binary is built in its own directory (get_binary_dirs()), with its own - # build/cmake-build-{platform} tree; chdir into it so find_elf_file()/get_cmake_path() - # (both CWD-relative) resolve against the right one, then restore CWD for the next binary. - original_cwd = os.getcwd() - for binary, directory in get_binary_dirs(manifest): - os.chdir(directory) - try: - for platform in platforms: - elf_path = find_elf_file(platform) - if elf_path is None: - print_error(f"ELF file not found for '{binary}' on {platform}") - return False - # v2's single app always installs at the fixed path bin/{platform}/app.{elf,so} - # (package_manifest_parse_v2()); v3 apps install under their own declared - # app.N.binary name (package_manifest_parse_v3()). - artifact_name = binary if is_v3_manifest(manifest) else "app" - platform_dir = os.path.join(target_path, "bin", platform) - os.makedirs(platform_dir, exist_ok=True) - shutil.copy(elf_path, os.path.join(platform_dir, f"{artifact_name}{get_artifact_extension(platform)}")) - finally: - os.chdir(original_cwd) - return True - -def package_intermediate_assets(target_path): - if os.path.isdir("assets"): - shutil.copytree("assets", os.path.join(target_path, "assets"), dirs_exist_ok=True) - -def package_intermediate(platforms, manifest): - target_path = os.path.abspath(os.path.join("build", "package-intermediate")) - if os.path.isdir(target_path): - shutil.rmtree(target_path) - os.makedirs(target_path, exist_ok=True) - if not package_intermediate_manifest(target_path): - return False - if not package_intermediate_binaries(target_path, platforms, manifest): - return False - package_intermediate_assets(target_path) - return True - -def package_name(manifest): - return os.path.join("build", f"{get_package_id(manifest)}.app") - -def package_all(manifest, platforms): - status = f"Building package with {platforms}" - print_status_busy(status) - if not package_intermediate(platforms, manifest): - print_status_error("Building package failed: missing inputs") - return False - # Create build/something.app - try: - tar_path = package_name(manifest) - with tarfile.open(tar_path, mode="w", format=tarfile.USTAR_FORMAT) as tar: - tar.add(os.path.join("build", "package-intermediate"), arcname="") - print_status_success(status) - return True - except Exception as e: - print_status_error(f"Building package failed: {e}") - return False - -#endregion Packaging - -def setup_environment(): - global ttbuild_path - os.makedirs(ttbuild_path, exist_ok=True) - -def build_action(manifest, arguments): - platform_arg = arguments.get("platform") - skip_build = arguments.has("skip-build") - platforms_to_build = get_manifest_target_platforms(manifest, platform_arg) - # Environment validation - validate_environment(platforms_to_build) - - if use_local_sdk: - global local_base_path - local_base_path = os.environ.get("TACTILITY_SDK_PATH") - validate_local_sdks(platforms_to_build, manifest["target.sdk"]) - - if not use_local_sdk: - sdk_json = read_sdk_json(manifest["target.sdk"]) - validate_self(sdk_json) - # Build - sdk_version = manifest["target.sdk"] - if not use_local_sdk: - if not sdk_download_all(sdk_version, platforms_to_build): - exit_with_error("Failed to download one or more SDKs") - - # A multi-binary app builds each of its binaries in its own subdirectory as an independent - # CMake project; a single-binary app has exactly one binary, built at the app root - # ("." from get_binary_dirs()), matching the tool's original single-binary behavior exactly. - original_cwd = os.getcwd() - for binary, directory in get_binary_dirs(manifest): - os.chdir(directory) - try: - ensure_cmakelists_up_to_date(binary if directory == "." else f"{get_package_id(manifest)}.{binary}") - if not build_all(sdk_version, platforms_to_build, skip_build): - return False - finally: - os.chdir(original_cwd) - - if not skip_build: - if not package_all(manifest, platforms_to_build): - return False - return True - -def clean_action(manifest): - cleaned_any = False - # The app root always has its own build/ (package-intermediate + the final .app/.elf, - # regardless of binary count). A multi-binary app additionally has one build/ per binary - # subdirectory (get_binary_dirs()), which "." (the root, handled above) doesn't repeat. - if os.path.exists("build"): - print_status_busy("Removing build/") - shutil.rmtree("build") - print_status_success("Removed build/") - cleaned_any = True - original_cwd = os.getcwd() - for _, directory in get_binary_dirs(manifest): - if directory == ".": - continue - os.chdir(directory) - try: - if os.path.exists("build"): - print_status_busy(f"Removing {directory}/build/") - shutil.rmtree("build") - print_status_success(f"Removed {directory}/build/") - cleaned_any = True - finally: - os.chdir(original_cwd) - if not cleaned_any: - print("Nothing to clean") - -def clear_cache_action(): - if os.path.exists(ttbuild_path): - print_status_busy(f"Removing {ttbuild_path}/") - shutil.rmtree(ttbuild_path) - print_status_success(f"Removed {ttbuild_path}/") - else: - print("Nothing to clear") - -def update_self_action(manifest): - if download_file(get_sdk_url(manifest["target.sdk"], "tactility.py"), "tactility.py"): - print("Updated") - else: - exit_with_error("Update failed") - -def get_device_info(host): - print_status_busy(f"Requesting device info") - url = get_url(host, "/info") - try: - response = requests.get(url, timeout=http_timeout_seconds) - if response.status_code != 200: - print_error("Run failed") - else: - print_status_success(f"Received device info:") - print(response.json()) - except requests.RequestException as e: - print_status_error(f"Device info request failed: {e}") - -def run_action(manifest, arguments): - host = arguments.get("host") - if host is None: - exit_with_error("Missing required argument: --host") - app_id = get_primary_app_id(manifest) - print_status_busy("Running") - url = get_url(host, "/app/run") - params = {'id': app_id} - try: - response = requests.post(url, params=params, timeout=http_timeout_seconds) - if response.status_code != 200: - print_error("Run failed") - else: - print_status_success("Running") - except requests.RequestException as e: - print_status_error(f"Running request failed: {e}") - -def install_action(manifest, arguments): - host = arguments.get("host") - if host is None: - exit_with_error("Missing required argument: --host") - package_path = package_name(manifest) - if not os.path.isfile(package_path): - print_status_error(f"Package not found: {package_path} (run 'build' first)") - return False - print_status_busy("Installing") - url = get_url(host, "/app/install") - try: - # Prepare multipart form data - with open(package_path, 'rb') as file: - files = { - 'app': file - } - response = requests.put(url, files=files, timeout=install_timeout_seconds) - if response.status_code != 200: - print_status_error("Install failed") - return False - else: - print_status_success("Installing") - return True - except requests.RequestException as e: - print_status_error(f"Install request failed: {e}") - return False - except IOError as e: - print_status_error(f"Install file error: {e}") - return False - -def uninstall_action(manifest, arguments): - host = arguments.get("host") - if host is None: - exit_with_error("Missing required argument: --host") - app_id = get_package_id(manifest) - print_status_busy("Uninstalling") - url = get_url(host, "/app/uninstall") - params = {'id': app_id} - try: - response = requests.put(url, params=params, timeout=http_timeout_seconds) - if response.status_code != 200: - print_status_error("Server responded that uninstall failed") - else: - print_status_success("Uninstalled") - except requests.RequestException as e: - print_status_error(f"Uninstall request failed: {e}") - -#region Main - -if __name__ == "__main__": - print(f"Tactility Build System v{ttbuild_version}") - - # Anchor the cache to the invocation directory, before --path (below) can chdir into the app. - ttbuild_path = os.path.abspath(ttbuild_path) - # Same for a relative local SDK path. - if os.environ.get("TACTILITY_SDK_PATH") is not None: - os.environ["TACTILITY_SDK_PATH"] = os.path.abspath(os.environ["TACTILITY_SDK_PATH"]) - - argv = sys.argv[1:] - if len(argv) == 0: - print_help() - sys.exit(1) - - parsed_command = parse_command_line(argv) - - if parsed_command.arguments.has("help"): - print_help() - sys.exit() - if parsed_command.action is None: - print_help() - sys.exit(1) - - verbose = parsed_command.arguments.has("verbose") - use_local_sdk = parsed_command.arguments.has("local-sdk") - - app_path = parsed_command.arguments.get("path") - if app_path is not None: - if not os.path.isdir(app_path) or not os.path.isfile(os.path.join(app_path, "manifest.properties")): - exit_with_error(f"App path not found or missing manifest.properties: {app_path}") - if verbose: - print_status_success(f"Switching to app directory: {app_path}") - os.chdir(app_path) - - # Environment setup - setup_environment() - if not os.path.isfile("manifest.properties"): - exit_with_error("manifest.properties not found") - manifest = read_manifest() - validate_manifest(manifest) - # Update SDK cache (tactility.py.json) - sdk_version = manifest["target.sdk"] - if not use_local_sdk and should_update_tool_json(sdk_version) and not update_tool_json(sdk_version): - exit_with_error("Failed to retrieve SDK info") - # Actions - action_arg = parsed_command.action - if action_arg == "build": - if not build_action(manifest, parsed_command.arguments): - sys.exit(1) - elif action_arg == "clean": - clean_action(manifest) - elif action_arg == "clearcache": - clear_cache_action() - elif action_arg == "updateself": - update_self_action(manifest) - elif action_arg == "run": - run_action(manifest, parsed_command.arguments) - elif action_arg == "install": - install_action(manifest, parsed_command.arguments) - elif action_arg == "uninstall": - uninstall_action(manifest, parsed_command.arguments) - elif action_arg == "bir" or action_arg == "brrr": - if build_action(manifest, parsed_command.arguments): - if install_action(manifest, parsed_command.arguments): - run_action(manifest, parsed_command.arguments) - else: - print_help() - exit_with_error("Unknown commandline parameter") - -#endregion Main diff --git a/tt b/tt new file mode 100755 index 0000000..635abbf --- /dev/null +++ b/tt @@ -0,0 +1,10 @@ +#!/bin/sh +# Runs tt.py from the directory of this script, on Linux and macOS +directory=$(dirname "$0") +if command -v python3 >/dev/null 2>&1; then + exec python3 "$directory/tt.py" "$@" +elif command -v python >/dev/null 2>&1; then + exec python "$directory/tt.py" "$@" +fi +echo "tt: Python 3 not found" >&2 +exit 1 diff --git a/tt.cmd b/tt.cmd new file mode 100755 index 0000000..fc685aa --- /dev/null +++ b/tt.cmd @@ -0,0 +1,9 @@ +@echo off +rem Runs tt.py from the directory of this script, on Windows +where py >nul 2>nul +if %ERRORLEVEL% equ 0 ( + py -3 "%~dp0tt.py" %* +) else ( + python "%~dp0tt.py" %* +) +exit /b %ERRORLEVEL% diff --git a/tt.py b/tt.py new file mode 100755 index 0000000..667c46c --- /dev/null +++ b/tt.py @@ -0,0 +1,273 @@ +#!/usr/bin/env python3 +# License: Apache License v2 +"""Runs the Tactility developer tools of the SDK version that an app targets. + +"tt [arguments]" runs .py from the tools of that SDK version. +When TACTILITY_SDK_PATH is set, its SDK and tools are used instead of downloading them. +Both ~/.tactility and TACTILITY_SDK_PATH hold sdk//tools/ and sdk//platform-/TactilitySDK/. +The tt (Linux, macOS) and tt.cmd (Windows) wrappers next to this script run it. +The SDK version is read from ./manifest.properties, or else from the first Apps/*/manifest.properties or apps/*/manifest.properties. +""" + +import hashlib +import json +import os +import re +import shutil +import subprocess +import sys +import time +import urllib.request +import zipfile + +TT_VERSION = "1.0.0" +CDN_URL = os.environ.get("TT_CDN", "https://cdn.tactilityproject.org") +HOME_DIRECTORY = os.path.join(os.path.expanduser("~"), ".tactility") +INDEX_VALIDITY_SECONDS = 3600 +COMMAND_PATTERN = re.compile(r"^[a-z0-9-]+$") + + +def fail(message): + print(f"tt: {message}", file=sys.stderr) + sys.exit(1) + + +def warn(message): + print(f"tt: {message}", file=sys.stderr) + + +def download(url, path): + """Downloads to a temporary file next to path, so a failed download keeps the existing file""" + request = urllib.request.Request(url, headers={"User-Agent": f"tt.py {TT_VERSION}"}) + partial_path = f"{path}.part" + try: + with urllib.request.urlopen(request, timeout=30) as response, open(partial_path, "wb") as file: + shutil.copyfileobj(response, file) + except OSError as error: + if os.path.exists(partial_path): + os.remove(partial_path) + warn(f"failed to download {url}: {error}") + return False + os.replace(partial_path, path) + return True + + +def read_properties(path): + properties = {} + with open(path) as file: + for line in file: + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + key, separator, value = line.partition("=") + if separator: + properties[key.strip()] = value.strip() + return properties + + +def find_manifest(): + if os.path.isfile("manifest.properties"): + return "manifest.properties" + for apps_directory in ("Apps", "apps"): + if not os.path.isdir(apps_directory): + continue + for name in sorted(os.listdir(apps_directory)): + path = os.path.join(apps_directory, name, "manifest.properties") + if os.path.isfile(path): + return path + return None + + +def resolve_target(): + """Returns the SDK version and platforms of the app manifest""" + manifest_path = find_manifest() + if manifest_path is None: + fail("no manifest.properties found in . or Apps/*/ or apps/*/") + manifest = read_properties(manifest_path) + version = manifest.get("target.sdk") + if not version: + fail(f"target.sdk not found in {manifest_path}") + if not re.match(r"^[0-9A-Za-z.+-]+$", version) or ".." in version: + fail(f"invalid target.sdk in {manifest_path}: {version}") + platforms = [platform.strip() for platform in manifest.get("target.platforms", "").split(",") if platform.strip()] + return version, platforms + + +def sdk_url(version, file_name): + return f"{CDN_URL}/sdk/{version}/{file_name}" + + +def extract_zip(zip_path, target_directory): + """Extracts into a new directory and then replaces target_directory, refusing entries outside of it""" + staging_directory = f"{target_directory}.new" + shutil.rmtree(staging_directory, ignore_errors=True) + os.makedirs(staging_directory) + root = os.path.realpath(staging_directory) + with zipfile.ZipFile(zip_path) as archive: + for member in archive.infolist(): + destination = os.path.realpath(os.path.join(root, member.filename)) + if not destination.startswith(root + os.sep): + shutil.rmtree(staging_directory, ignore_errors=True) + fail(f"invalid entry in {zip_path}: {member.filename}") + archive.extractall(root) + shutil.rmtree(target_directory, ignore_errors=True) + os.replace(staging_directory, target_directory) + + +def file_sha256(path): + digest = hashlib.sha256() + with open(path, "rb") as file: + for block in iter(lambda: file.read(65536), b""): + digest.update(block) + return digest.hexdigest() + + +def load_index(version, version_directory): + index_path = os.path.join(version_directory, "index.json") + is_fresh = os.path.isfile(index_path) and time.time() - os.path.getmtime(index_path) < INDEX_VALIDITY_SECONDS + if not is_fresh and not download(sdk_url(version, "index.json"), index_path): + if not os.path.isfile(index_path): + fail(f"SDK {version} is not available: check your internet connection and that this SDK version exists") + warn(f"using the cached information of SDK {version}") + with open(index_path) as file: + return json.load(file) + + +def version_path(sdk_path, version): + return os.path.join(sdk_path, "sdk", version) + + +def install_tools(version, version_directory, index): + tools = index.get("tools") + if tools is None: + fail(f"SDK {version} has no tools") + tools_directory = os.path.join(version_directory, "tools") + hash_path = os.path.join(version_directory, "tools.sha256") + if os.path.isdir(tools_directory) and os.path.isfile(hash_path): + with open(hash_path) as file: + if file.read().strip() == tools["sha256"]: + return + print(f"Downloading the tools of SDK {version}", file=sys.stderr) + zip_path = os.path.join(version_directory, "tools.zip") + if not download(sdk_url(version, tools["file"]), zip_path): + if os.path.isdir(tools_directory): + warn(f"using the cached tools of SDK {version}") + return + fail(f"failed to download the tools of SDK {version}") + if file_sha256(zip_path) != tools["sha256"]: + os.remove(zip_path) + fail(f"the tools of SDK {version} don't match their checksum") + extract_zip(zip_path, tools_directory) + os.remove(zip_path) + with open(hash_path, "w") as file: + file.write(tools["sha256"]) + + +def install_platforms(version, version_directory, index, platforms): + available = index.get("platforms", {}) + for platform in platforms: + platform_directory = os.path.join(version_directory, f"platform-{platform}") + if os.path.isdir(os.path.join(platform_directory, "TactilitySDK")): + continue + if platform not in available: + fail(f"platform {platform} is not available in SDK {version}") + print(f"Downloading SDK {version} for {platform}", file=sys.stderr) + zip_path = os.path.join(version_directory, f"platform-{platform}.zip") + if not download(sdk_url(version, available[platform]), zip_path): + fail(f"failed to download SDK {version} for {platform}") + extract_zip(zip_path, os.path.join(platform_directory, "TactilitySDK")) + os.remove(zip_path) + # The commit of the SDK build, to find out when a -dev version was published again + with open(os.path.join(platform_directory, "commit"), "w") as file: + file.write(index.get("gitCommit", "")) + + +def prepare_sdk(version, platforms): + version_directory = version_path(HOME_DIRECTORY, version) + os.makedirs(version_directory, exist_ok=True) + index = load_index(version, version_directory) + install_tools(version, version_directory, index) + install_platforms(version, version_directory, index, platforms) + return version_directory + + +def is_command(name): + """Tools named _*.py are internal to the tools and are never commands""" + return COMMAND_PATTERN.match(name) is not None and not name.startswith("_") + + +def list_commands(tools_directory): + return sorted(name[:-3] for name in os.listdir(tools_directory) if name.endswith(".py") and is_command(name[:-3])) + + +def print_help(version, tools_directory): + """Without an app manifest, the version and its commands are unknown""" + print("Usage: tt [arguments]") + print("") + if version is None: + print("Run this in an app folder, or in a folder with Apps/*/ or apps/*/, to list the commands of its SDK") + else: + print(f"SDK {version}, commands:") + for command in list_commands(tools_directory): + print(f" {command}") + print("") + print("The SDK and tools of TACTILITY_SDK_PATH are used when it is set") + + +def tool_environment(version, sdk_path, is_local): + environment = dict(os.environ) + environment["TACTILITY_SDK_VERSION"] = version + environment["TACTILITY_SDK_PATH"] = sdk_path + environment["TT_DIRECTORY"] = os.path.dirname(os.path.abspath(__file__)) + environment["TT_VERSION"] = TT_VERSION + # The SDK in TACTILITY_SDK_PATH is managed by tt unless it is local + if is_local: + environment["TT_LOCAL"] = "1" + else: + environment.pop("TT_LOCAL", None) + return environment + + +def run_tool(tool_path, arguments, environment): + command = [sys.executable, tool_path, *arguments] + if os.name == "posix": + sys.stdout.flush() + sys.stderr.flush() + os.execve(sys.executable, command, environment) + sys.exit(subprocess.run(command, env=environment).returncode) + + +def main(arguments): + command = arguments[0] if arguments else None + is_help = command is None or command in ("help", "--help") + if not is_help and not is_command(command): + fail(f"command not found: {command}") + if is_help and find_manifest() is None: + print_help(None, None) + return + + version, platforms = resolve_target() + # Empty counts as unset, like after "export TACTILITY_SDK_PATH=" + local_path = os.environ.get("TACTILITY_SDK_PATH") or None + is_local = local_path is not None + if is_local: + sdk_path = os.path.abspath(local_path) + print(f"tt: using the local SDK at {sdk_path}", file=sys.stderr) + tools_directory = os.path.join(version_path(sdk_path, version), "tools") + if not os.path.isdir(tools_directory): + fail(f"tools not found: {tools_directory}") + else: + sdk_path = HOME_DIRECTORY + tools_directory = os.path.join(prepare_sdk(version, platforms), "tools") + environment = tool_environment(version, sdk_path, is_local) + if is_help: + print_help(version, tools_directory) + return + tool_path = os.path.join(tools_directory, f"{command}.py") + if not os.path.isfile(tool_path): + fail(f"command not found: {command}") + run_tool(tool_path, arguments[1:], environment) + + +if __name__ == "__main__": + main(sys.argv[1:])