diff --git a/README.md b/README.md index 105fa36..506682b 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,10 @@ uFBT uses your system's Python for running bootstrap code. Minimal supported ver On first run, uFBT will download and install required SDK components from `release` update channel of official firmware. For more information on how to switch to a different version of the SDK, see [Managing the SDK](#managing-the-sdk) section. +### Using pyenv + +If you are using `pyenv` to manage python versions, after installation you may need to run `pyenv rehash` to generate [shim](https://github.com/pyenv/pyenv#understanding-shims) for `ufbt` command. + ## Usage ### Building & running your application @@ -57,8 +61,15 @@ To update the SDK, run `ufbt update`. This will download and install all require - uFBT can also download and update the SDK from any **fixed URL**. To do this, run `ufbt update --url=`. - To use a **local copy** of the SDK, run `ufbt update --local=`. This will use the SDK located in `` instead of downloading it. Useful for testing local builds of the SDK. -uFBT stores its state in `.ufbt` subfolder in your home directory. You can override this location by setting `UFBT_HOME` environment variable. +### Global and per-project SDK management + +By default, uFBT stores its state - SDK and toolchain - in `.ufbt` subfolder of your home directory. You can override this location by setting `UFBT_HOME` environment variable. + +uFBT also supports dotenv (`.env`) files, containing environment variable overrides for the project in current directory. Most commonly, you will want to use this to override the default state directory to a local one, so that your project could use a specific version and/or hardware target of the SDK. +You can enable dotenv mode for current directory by running `ufbt dotenv_create`. This will create `.env` file in current directory with default values, linking SDK state to `.ufbt` subfolder in current directory, and creating a symlink for toolchain to `.ufbt/toolchain` in your home directory. You can then edit `.env` file to further customize the environment. + +You can also specify additional options when creating the `.env` file. See `ufbt dotenv_create --help` for more information. ### ufbt-bootstrap @@ -69,3 +80,9 @@ Updating the SDK is handled by uFBT component called _bootstrap_. It has a dedic If something goes wrong and uFBT state becomes corrupted, you can reset it by running `ufbt clean`. If that doesn't work, you can try removing `.ufbt` subfolder manually from your home folder. `ufbt-bootstrap` and SDK-related `ufbt` subcommands accept `--verbose` option that will print additional debug information. + +## Contributing + +uFBT is a small tool and does not contain the actual implementation of build system, project templates or toolchain. It functions as a downloader and manager of SDK components that are packaged [alongside with Flipper firmware](https://github.com/flipperdevices/flipperzero-firmware/tree/dev/scripts/ufbt). + +Issues and pull requests regarding `ufbt-bootstrap` features like SDK management should be reported to this project, and the rest - related to actual application development - to [Flipper firmware repo](https://github.com/flipperdevices/flipperzero-firmware/issues). diff --git a/VERSION.txt b/VERSION.txt index f33f5ed..3a4036f 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -1 +1 @@ -0.2.4.3 \ No newline at end of file +0.2.5 diff --git a/test.py b/test.py index 9453875..757c127 100644 --- a/test.py +++ b/test.py @@ -1,21 +1,23 @@ import json import subprocess import unittest +from pathlib import Path +from tempfile import TemporaryDirectory # ufbt invokation & json status output -def ufbt_status() -> dict: +def ufbt_status(cwd=None) -> dict: # Call "ufbt status --json" and return the parsed json try: - status = subprocess.check_output(["ufbt", "status", "--json"]) + status = subprocess.check_output(["ufbt", "status", "--json"], cwd=cwd) except subprocess.CalledProcessError as e: status = e.output return json.loads(status) -def ufbt_exec(args): +def ufbt_exec(args, cwd=None): # Call "ufbt" with the given args and return the parsed json - return subprocess.check_output(["ufbt"] + args) + return subprocess.check_output(["ufbt"] + args, cwd=cwd) # Test initial deployment @@ -84,3 +86,74 @@ def test_target_mode_switches(self): ufbt_exec(["update"]) status = ufbt_status() self.assertEqual(previous_status, status) + + def test_dotenv_basic(self): + ufbt_exec(["clean"]) + status = ufbt_status() + self.assertEqual(status.get("error"), "SDK is not deployed") + + ufbt_exec(["update", "-t", "f7"]) + status = ufbt_status() + self.assertEqual(status.get("target"), "f7") + self.assertEqual(status.get("mode"), "channel") + self.assertEqual(status.get("details", {}).get("channel"), "release") + + with TemporaryDirectory() as tmpdir: + local_dir = Path(tmpdir) / "local_env" + local_dir.mkdir(exist_ok=False) + + ufbt_exec(["dotenv_create"], cwd=local_dir) + status = ufbt_status(cwd=local_dir) + self.assertEqual(status.get("target"), None) + self.assertIn( + str(local_dir.absolute()), str(Path(status.get("state_dir")).absolute()) + ) + self.assertEqual(status.get("error"), "SDK is not deployed") + + ufbt_exec(["update", "-b", "dev"], cwd=local_dir) + status = ufbt_status(cwd=local_dir) + self.assertEqual(status.get("target"), "f7") + self.assertEqual(status.get("mode"), "branch") + self.assertEqual(status.get("details", {}).get("branch", ""), "dev") + + status = ufbt_status() + self.assertEqual(status.get("target"), "f7") + self.assertEqual(status.get("mode"), "channel") + + def test_dotenv_notoolchain(self): + with TemporaryDirectory() as tmpdir: + local_dir = Path(tmpdir) / "local_env" + local_dir.mkdir(exist_ok=False) + + ufbt_exec(["dotenv_create"], cwd=local_dir) + status = ufbt_status(cwd=local_dir) + + toolchain_path_local = status.get("toolchain_dir", "") + self.assertTrue(Path(toolchain_path_local).is_symlink()) + + # 2nd env + local_dir2 = Path(tmpdir) / "local_env2" + local_dir2.mkdir(exist_ok=False) + + ufbt_exec(["dotenv_create", "--no-link-toolchain"], cwd=local_dir2) + status = ufbt_status(cwd=local_dir2) + + toolchain_path_local2 = status.get("toolchain_dir", "") + self.assertFalse(Path(toolchain_path_local2).exists()) + + def test_path_with_spaces(self): + ufbt_exec(["clean"]) + status = ufbt_status() + self.assertEqual(status.get("error"), "SDK is not deployed") + + with TemporaryDirectory() as tmpdir: + local_dir = Path(tmpdir) / "path with spaces" + local_dir.mkdir(exist_ok=False) + + ufbt_exec(["dotenv_create"], cwd=local_dir) + ufbt_exec(["update"], cwd=local_dir) + status = ufbt_status(cwd=local_dir) + self.assertNotIn("error", status) + + ufbt_exec(["create", "APPID=myapp"], cwd=local_dir) + ufbt_exec(["faps"], cwd=local_dir) diff --git a/ufbt/__init__.py b/ufbt/__init__.py index 221475c..f066a2e 100644 --- a/ufbt/__init__.py +++ b/ufbt/__init__.py @@ -23,18 +23,50 @@ import sys from .bootstrap import ( + DEFAULT_UFBT_HOME, + ENV_FILE_NAME, bootstrap_cli, bootstrap_subcommands, get_ufbt_package_version, - DEFAULT_UFBT_HOME, ) __version__ = get_ufbt_package_version() +def _load_env_file(env_file): + """ + Minimalistic implementation of env file parser. + Only supports lines in format `KEY=VALUE`. + Ignores comments (lines starting with #) and empty lines. + """ + if not os.path.exists(env_file): + return {} + env_vars = {} + with open(env_file) as f: + for line in f: + line = line.strip() + if not line or line.startswith("#"): + continue + key, value = line.split("=", 1) + env_vars[key] = value + return env_vars + + def ufbt_cli(): + # load environment variables from .env file in current directory + try: + env_vars = _load_env_file(ENV_FILE_NAME) + if env_vars: + os.environ.update(env_vars) + except Exception as e: + print(f"Failed to load environment variables from {ENV_FILE_NAME}: {e}") + return 2 + if not os.environ.get("UFBT_HOME"): os.environ["UFBT_HOME"] = DEFAULT_UFBT_HOME + + os.environ["UFBT_HOME"] = os.path.abspath(os.environ["UFBT_HOME"]) + # ufbt impl uses UFBT_STATE_DIR internally, not UFBT_HOME os.environ["UFBT_STATE_DIR"] = os.environ["UFBT_HOME"] if not os.environ.get("FBT_TOOLCHAIN_PATH"): diff --git a/ufbt/bootstrap.py b/ufbt/bootstrap.py index 4cefb18..ad7f57a 100644 --- a/ufbt/bootstrap.py +++ b/ufbt/bootstrap.py @@ -22,6 +22,7 @@ import json import logging import os +import platform import re import shutil import sys @@ -38,6 +39,8 @@ log = logging.getLogger(__name__) DEFAULT_UFBT_HOME = os.path.expanduser("~/.ufbt") +ENV_FILE_NAME = ".env" +STATE_DIR_TOOLCHAIN_SUBDIR = "toolchain" def get_ufbt_package_version(): @@ -493,14 +496,19 @@ def create_for_task(task: SdkDeployTask, download_dir: str) -> BaseSdkLoader: class UfbtSdkDeployer: UFBT_STATE_FILE_NAME = "ufbt_state.json" - def __init__(self, ufbt_state_dir: str): + def __init__(self, ufbt_state_dir: str, toolchain_dir: str = None): self.ufbt_state_dir = Path(ufbt_state_dir) self.download_dir = self.ufbt_state_dir / "download" self.current_sdk_dir = self.ufbt_state_dir / "current" - self.toolchain_dir = ( - Path(os.environ.get("FBT_TOOLCHAIN_PATH", self.ufbt_state_dir.absolute())) - / "toolchain" - ) + if toolchain_dir: + self.toolchain_dir = self.ufbt_state_dir / toolchain_dir + else: + self.toolchain_dir = ( + Path( + os.environ.get("FBT_TOOLCHAIN_PATH", self.ufbt_state_dir.absolute()) + ) + / STATE_DIR_TOOLCHAIN_SUBDIR + ) self.state_file = self.current_sdk_dir / self.UFBT_STATE_FILE_NAME def get_previous_task(self) -> Optional[SdkDeployTask]: @@ -582,6 +590,9 @@ def __init__(self): super().__init__(self.COMMAND, "Update uFBT SDK") def _add_arguments(self, parser: argparse.ArgumentParser) -> None: + parser.description = """Update uFBT SDK. By default uses the last used target and mode. + Otherwise deploys latest release.""" + parser.add_argument( "--hw-target", "-t", @@ -611,6 +622,8 @@ def __init__(self): super().__init__(self.COMMAND, "Clean uFBT SDK state") def _add_arguments(self, parser: argparse.ArgumentParser): + parser.description = """Clean up uFBT internal state. By default cleans current SDK state. + For cleaning app build artifacts, use 'ufbt -c' instead.""" parser.add_argument( "--downloads", help="Clean downloads", @@ -662,6 +675,8 @@ def __init__(self): super().__init__(self.COMMAND, "Show uFBT SDK status") def _add_arguments(self, parser: argparse.ArgumentParser) -> None: + parser.description = """Show uFBT status - deployment paths and SDK version.""" + parser.add_argument( "--json", help="Print status in JSON format", @@ -702,6 +717,7 @@ def _func(self, args) -> int: else: state_data.update({"error": "SDK is not deployed"}) + skip_error_message = False if key := args.status_key: if key not in state_data: log.error(f"Unknown status key {key}") @@ -714,13 +730,96 @@ def _func(self, args) -> int: if args.json: print(json.dumps(state_data)) else: + skip_error_message = True for key, value in state_data.items(): log.info(f"{self.STATUS_FIELDS[key]:<15} {value}") - return 1 if state_data.get("error") else 0 + if state_data.get("error"): + if not skip_error_message: + log.error("Status error: {}".format(state_data.get("error"))) + return 1 + return 0 + + +class LocalEnvSubcommand(CliSubcommand): + COMMAND = "dotenv_create" + + def __init__(self): + super().__init__(self.COMMAND, "Create a local environment for uFBT") + + def _add_arguments(self, parser: argparse.ArgumentParser) -> None: + parser.description = f"""Create a dotenv ({ENV_FILE_NAME}) file in current directory with environment variables for uFBT. + Designed for per-project SDK management. + If {ENV_FILE_NAME} file already exists, this command will refuse to overwrite it. + """ + parser.add_argument( + "--state-dir", + help="Directory to create the local environment in. Defaults to '.ufbt'.", + default=".ufbt", + ) + + parser.add_argument( + "--no-link-toolchain", + help="Don't link toolchain directory to the local environment and create a local copy", + action="store_true", + default=False, + ) + + @staticmethod + def _link_dir(target_path, source_path): + log.info(f"Linking {target_path=} to {source_path=}") + if os.path.lexists(target_path) or os.path.exists(target_path): + os.unlink(target_path) + if platform.system() == "Windows": + # Crete junction - does not require admin rights + import _winapi + + if not os.path.isdir(source_path): + raise ValueError(f"Source path {source_path} is not a directory") + + if not os.path.exists(target_path): + _winapi.CreateJunction(source_path, target_path) + else: + os.symlink(source_path, target_path) + + def _func(self, args) -> int: + if os.path.exists(ENV_FILE_NAME): + log.error( + f"File {ENV_FILE_NAME} already exists, refusing to overwrite. Please remove or update it manually." + ) + return 1 + + env_sdk_deployer = UfbtSdkDeployer(args.state_dir, STATE_DIR_TOOLCHAIN_SUBDIR) + # Will extract toolchain dir from env + default_sdk_deployer = UfbtSdkDeployer(args.ufbt_home) + + env_sdk_deployer.ufbt_state_dir.mkdir(parents=True, exist_ok=True) + if not args.no_link_toolchain: + env_sdk_deployer.ufbt_state_dir.mkdir(parents=True, exist_ok=True) + default_sdk_deployer.toolchain_dir.mkdir(parents=True, exist_ok=True) + self._link_dir( + str(env_sdk_deployer.toolchain_dir.absolute()), + str(default_sdk_deployer.toolchain_dir.absolute()), + ) + + env_vars = { + "UFBT_HOME": args.state_dir, + # "TOOLCHAIN_PATH": str(env_sdk_deployer.toolchain_dir.absolute()), + } + + with open(ENV_FILE_NAME, "wt") as f: + for key, value in env_vars.items(): + f.write(f"{key}={value}\n") + + return 0 -bootstrap_subcommand_classes = (UpdateSubcommand, CleanSubcommand, StatusSubcommand) +bootstrap_subcommand_classes = ( + UpdateSubcommand, + CleanSubcommand, + StatusSubcommand, + LocalEnvSubcommand, +) bootstrap_subcommands = ( subcommand_cls.COMMAND for subcommand_cls in bootstrap_subcommand_classes