Skip to content

Commit

Permalink
Dotenv file support (#30)
Browse files Browse the repository at this point in the history
* ufbt-bootstrap: added `dotenv_create` command; ufbt: added support for .env files in project directories to specify ufbt state location & other env variables

* tests: new tests for dotenv and paths with spaces

* ufbt: forcing toolchain path creation when symlinking

* ufbt: status: better error output

* docs: dotenv_create

* docs: contribution info

* ufbt: added checks for .env loading

* Organized imports

* bootstrap: fixed error code reporting in certain modes
  • Loading branch information
hedger authored Dec 18, 2023
1 parent de5a03f commit f47fc79
Show file tree
Hide file tree
Showing 5 changed files with 228 additions and 14 deletions.
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

0 comments on commit f47fc79

Please sign in to comment.