Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Env file support #30

Merged
merged 9 commits into from
Dec 18, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,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=<url>`.
- To use a **local copy** of the SDK, run `ufbt update --local=<path>`. This will use the SDK located in `<path>` 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

Expand All @@ -73,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).
2 changes: 1 addition & 1 deletion VERSION.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.2.4.4
0.2.5
81 changes: 77 additions & 4 deletions test.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
34 changes: 33 additions & 1 deletion ufbt/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"):
Expand Down
110 changes: 103 additions & 7 deletions ufbt/bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import json
import logging
import os
import platform
import re
import shutil
import sys
Expand All @@ -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():
Expand Down Expand Up @@ -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]:
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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}")
Expand All @@ -714,16 +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}")

if state_data.get("error"):
log.error("Status error: {}".format(state_data.get("error")))
if not skip_error_message:
log.error("Status error: {}".format(state_data.get("error")))
return 1
return 0


bootstrap_subcommand_classes = (UpdateSubcommand, CleanSubcommand, StatusSubcommand)
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,
LocalEnvSubcommand,
)

bootstrap_subcommands = (
subcommand_cls.COMMAND for subcommand_cls in bootstrap_subcommand_classes
Expand Down
Loading