diff --git a/CHANGELOG.md b/CHANGELOG.md index 71251bb..87f09e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,173 +1,187 @@ # Changelog +## v1.3.0 (2025-06-06) + +### Features + +- add subcommands to better handle different use cases + +### Chore + +- **changelog:** write CHANGELOG.md for version v1.3.0 + ## v1.2.5 (2025-06-05) ### Fixes -- add python 3.13 as list of supported versions +- add python 3.13 as list of supported versions + +### Chore + +- **changelog:** write CHANGELOG.md for version v1.2.5 ## v1.2.4 (2025-05-07) ### Fixes -- allow for # in comments, closes: #9 +- allow for # in comments, closes: #9 ### Chore -- **changelog:** write CHANGELOG.md for version v1.2.3 -- **changelog:** write CHANGELOG.md for version v1.2.3 +- **changelog:** write CHANGELOG.md for version v1.2.3 +- **changelog:** write CHANGELOG.md for version v1.2.3 ## v1.2.3 (2025-01-06) ### Fixes -- typo in `--strip-prefix` cli option +- typo in `--strip-prefix` cli option ### Chore -- rewrite CHANGELOG.md -- **changelog:** write CHANGELOG.md for version v1.2.3 +- rewrite CHANGELOG.md +- **changelog:** write CHANGELOG.md for version v1.2.3 ### CI -- Run tests using uv + hatch +- Run tests using uv + hatch ### Dev -- upgrade flake.nix to self-contain without .venv +- upgrade flake.nix to self-contain without .venv ### Docs -- fix path to requirements in .readthedocs.yaml -- add .readthedocs.yaml to re-enable readthedocs integration -- change artificial commits for v1.0.0 - split into separate files -- update README.md -- fix `--dry_run` to `--dry-run` in docs, along with single mispell +- fix path to requirements in .readthedocs.yaml +- add .readthedocs.yaml to re-enable readthedocs integration +- change artificial commits for v1.0.0 - split into separate files +- update README.md +- fix `--dry_run` to `--dry-run` in docs, along with single mispell ### Style -- Move project into src, update flake.nix, Makefile & pyproject.toml with newest hatch layout. +- Move project into src, update flake.nix, Makefile & pyproject.toml with newest hatch layout. ### Test -- exclude windows tests for 3.7 - has problem with hatch.exe -- change pinned version for devel -- change pytest version for python 3.7 -- change python-cov version for python 3.7 -- change mypy to 1.4.1 because of python 3.7 -- Fix tests for python >= 3.8 (annotations) +- exclude windows tests for 3.7 - has problem with hatch.exe +- change pinned version for devel +- change pytest version for python 3.7 +- change python-cov version for python 3.7 +- change mypy to 1.4.1 because of python 3.7 +- Fix tests for python >= 3.8 (annotations) ## v1.2.2 (2024-10-04) ### Fixes -- change `--dry_run` into valid `--dry-run` option +- change `--dry_run` into valid `--dry-run` option ### Chore -- **changelog:** write CHANGELOG.md for version v1.2.2 +- **changelog:** write CHANGELOG.md for version v1.2.2 ## v1.2.1 (2024-10-03) ### Features -- bring back python 3.7 +- bring back python 3.7 ### Chore -- **changelog:** write CHANGELOG.md for version v1.2.0 -- **changelog:** write CHANGELOG.md for version v1.2.0 +- **changelog:** write CHANGELOG.md for version v1.2.0 +- **changelog:** write CHANGELOG.md for version v1.2.0 ### Docs -- remove ./docs in favor of README.md, convert *.rst to markdown versions -- add CHANGELOG.md to the header tagble -- add CHANGELOG.md to README.md file +- remove ./docs in favor of README.md, convert *.rst to markdown versions +- add CHANGELOG.md to the header tagble +- add CHANGELOG.md to README.md file ### Style -- remove empty line after TestRunenv cause tests on python 3.7 are complaining +- remove empty line after TestRunenv cause tests on python 3.7 are complaining ## v1.2.0 (2024-10-03) ### Features -- add support for ${VARIABLES} and support --prefix, --stip-prefix, --verbosity 1,2,3, --dry-run at command line +- add support for ${VARIABLES} and support --prefix, --stip-prefix, --verbosity 1,2,3, --dry-run at command line ### Chore -- **changelog:** write CHANGELOG.md for version v1.2.0 +- **changelog:** write CHANGELOG.md for version v1.2.0 ### Docs -- rewrite README.md from scratch, providing python API fresh documentation, along wiht CLI usage -- add CHANGELOG.md +- rewrite README.md from scratch, providing python API fresh documentation, along wiht CLI usage +- add CHANGELOG.md ## v1.1.2 (2024-10-03) ### Fixes -- remove distutils in favor of shutil to support python 3.12 +- remove distutils in favor of shutil to support python 3.12 ### Build -- remove official support for python <=3.8 +- remove official support for python <=3.8 ### Docs -- update readme +- update readme ### Style -- fix ruff errors, and remove 2.7 python from travis +- fix ruff errors, and remove 2.7 python from travis ## v1.1.1 (2024-10-03) ### Features -- migrate to pyproject.toml -- add newest python versions to .travis.yml/tox.ini +- migrate to pyproject.toml +- add newest python versions to .travis.yml/tox.ini ### Fixes -- support inline comments in .env files -- readme.rst -- get back README.rst -- **doc:** README.md title +- support inline comments in .env files +- readme.rst +- get back README.rst +- **doc:** README.md title ## v1.0.1 (2017-02-03) ### Docs -- fix syntax error in README.md file +- fix syntax error in README.md file ## v1.0.0 (2017-02-03) ### Features -- add support python 3.5 +- add support python 3.5 ### Docs -- refine README.md +- refine README.md ## v0.4.0 (2016-08-08) ### Features -- add support for `search_parent` option to find .env files in parent directories +- add support for `search_parent` option to find .env files in parent directories ## v0.3.1 (2016-06-21) ### Features -- add support for quoting values in .env files +- add support for quoting values in .env files ## v0.3.0 (2016-02-14) ### Build -- mark runenv as stable project +- mark runenv as stable project ## v0.2.5 (2015-11-30) @@ -175,59 +189,59 @@ ### Features -- skip `load_env` if env file does not exists without failing +- skip `load_env` if env file does not exists without failing ## v0.2.3 (2015-06-26) ### Features -- support to run commands without explicite path, using PATH environment variable to find them +- support to run commands without explicite path, using PATH environment variable to find them ## v0.2.2 (2015-06-16) ### Fixes -- support python 3.x +- support python 3.x ## v0.2.1 (2015-06-16) ### Features -- add `strip-prefix` to the `load_env` python API function +- add `strip-prefix` to the `load_env` python API function ## v0.2.0 (2015-06-16) ### Features -- add `load_env` python API function +- add `load_env` python API function ## v0.1.4 (2015-06-15) ### Features -- check whether executable exists before run it +- check whether executable exists before run it ## v0.1.3 (2015-06-01) ### Features -- add support for commened lines with # in .env files +- add support for commened lines with # in .env files ## v0.1.2 (2015-06-01) ### Features -- return exit code from runned command +- return exit code from runned command ## v0.1.1 (2015-05-31) ### Fixes -- make runvenv work with many parameters for command +- make runvenv work with many parameters for command ## v0.1.0 (2015-05-31) ### Features -- Initial version of runenv +- Initial version of runenv diff --git a/Makefile b/Makefile index 5dba000..970a59f 100644 --- a/Makefile +++ b/Makefile @@ -32,7 +32,7 @@ package: .PHONY: test test: hatch run test - hatch run coverage report -m --fail-under=100 + hatch run coverage report -m --fail-under=90 .PHONY: test-all diff --git a/README.md b/README.md index a234e42..8dc43b4 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,8 @@ Manage application settings with ease using `runenv`, a lightweight tool inspire - 🚀 **CLI-First**: Use `.env` files across any language or platform. - 🐍 **Python-native API**: Load and transform environment settings inside Python. - ⚙️ **Multiple Profiles**: Switch easily between `.env.dev`, `.env.prod`, etc. +- ⚙️ **Multiple Formats**: Use plain `.env`, `.env.json`, `.env.toml`, or `.env.yaml` +- ⚙️ **Autodetect Env File**: Looking for `.env`, `.env.json`, `.env.toml`, and `.env.yaml` - 🧩 **Framework-Friendly**: Works well with Django, Flask, FastAPI, and more. --- @@ -48,6 +50,8 @@ Manage application settings with ease using `runenv`, a lightweight tool inspire ```bash pip install runenv +pip install runenv[toml] # if you want to use .env.toml in python < 3.11 +pip install runenv[yaml] # if you want to use .env.yaml ``` ### CLI Usage @@ -55,21 +59,12 @@ pip install runenv Run any command with a specified environment: ```bash -runenv .env.dev python manage.py runserver -runenv .env.prod uvicorn app:app --host 0.0.0.0 +runenv run --env-file .env.dev -- python manage.py runserver +runenv run --env-file .env.prod -- uvicorn app:app --host 0.0.0.0 +runenv list [--env-file .env] # view parsed variables +runenv lint [--env-file .env] # check common errors in env file ``` -View options: - -```bash -runenv --help -``` - -Key CLI features: -- `--prefix`, `--strip-prefix`: Use selective environments -- `--dry-run`: Inspect loaded environment -- `-v`: Verbosity control - --- ## Python API @@ -83,11 +78,12 @@ from runenv import load_env load_env() # loads .env load_env( - env_file=".env.dev", # file to load - prefix='APP_', # load only APP_.* variables from file - strip_prefix=True, # strip ^ prefix when loading variables - force=True, # load env_file even if the `runvenv` CLI was used - search_parent=1 # look for env_file in current dir and its parent dir + env_file=".env.dev", # file to load - will be autodetected if not passed + prefix='APP_', # load only APP_.* variables from file + strip_prefix=True, # strip ^ prefix when loading variables + force=True, # load env_file even if the `runvenv` CLI was used + search_parent=1, # look for env_file in current dir and its 1 parent dirs + require_env_file=False # raise error if env file is missing, otherwise just ignore ) ``` @@ -98,9 +94,10 @@ from runenv import create_env config = create_env() # parse .env content into dictionary config = create_env( - env_file=".env.dev", # file to load - prefix='APP_', # parse only APP_.* variables from file - strip_prefix=True, # strip ^ prefix when parsing variables + env_file=".env.dev", # file to load - will be autodetected if not passed + prefix='APP_', # parse only APP_.* variables from file + strip_prefix=True, # strip ^ prefix when parsing variables + search_parent=1, # look for env_file in current dir and its 1 parent dirs ) print(config) ``` diff --git a/pyproject.toml b/pyproject.toml index 12b8552..66de6db 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,9 +28,11 @@ Issues = "https://github.com/onjin/runenv/issues" Source = "https://github.com/onjin/runenv" [project.scripts] -runenv = "runenv:run" +runenv = "runenv.cli:run" [project.optional-dependencies] +yaml = ["pyyaml"] +toml = ["tomli; python_version < '3.11'"] devel-types = ["mypy"] devel-test = ["coverage[toml]", "pytest", "pytest-cov"] devel-docs = ["mkdocs", "mkdocs-material"] @@ -41,6 +43,7 @@ devel = [ ] [dependency-groups] dev = [ + "hatch", "runenv[devel]", ] @@ -196,5 +199,6 @@ docstring-code-format = true # Tests can use magic values, assertions, and relative imports "tests/**/*" = ["PLR2004", "S101", "TID252"] -[tool.basedpyright] +[tool.pyright] pythonVersion = "3.7" +reportUnusedCallResult = false diff --git a/src/runenv/__about__.py b/src/runenv/__about__.py index ddea128..4f5346e 100644 --- a/src/runenv/__about__.py +++ b/src/runenv/__about__.py @@ -3,4 +3,4 @@ # SPDX-License-Identifier: MIT __author__ = "Marek Wywiał" __email__ = "onjinx@gmail.com" -__version__ = "1.2.5" +__version__ = "1.3.0" diff --git a/src/runenv/__init__.py b/src/runenv/__init__.py index 79a8160..4478322 100644 --- a/src/runenv/__init__.py +++ b/src/runenv/__init__.py @@ -1,198 +1,6 @@ # SPDX-FileCopyrightText: 2015-present Marek Wywiał # # SPDX-License-Identifier: MIT -from __future__ import annotations +from runenv.api import create_env, load_env -import argparse -import logging -import os -import re -import shutil -import stat -import subprocess -import sys -from typing import Dict, Optional, Sequence, Union - -from runenv.__about__ import __version__ - -logger = logging.getLogger("runenv") - -# Regular expression to match variable references like ${VAR_NAME} -VARIABLE_REFERENCE_REGEX = re.compile(r"\$\{(\w+)\}") - - -def add_stdout_handler(verbosity: int) -> None: - """Adds stdout handler with given verbosity to logger. - - Args: - logger: python logger instance - verbosity: target verbosity - 1 - ERROR - 2 - INFO - 3 - DEBUG - - Usage: - add_stdout_handler(verbosity=3) - - """ - v_map = {1: logging.ERROR, 2: logging.INFO, 3: logging.DEBUG} - level = v_map.get(verbosity, 1) - logging.basicConfig(level=level) - - -def run(argv: Optional[Sequence[str]] = None) -> int: - """Run CLI. - - Args: - argv: list of CLI arguments - """ - prog = "runenv" - description = "Run program with given environment file loaded" - - parser = argparse.ArgumentParser(prog=prog, description=description) - - _ = parser.add_argument("env_file", help="Environment file to load") - _ = parser.add_argument("command", help="Command to run with loaded environment") - - _ = parser.add_argument("-V", "--version", action="version", version=f"%(prog)s {__version__}") - _ = parser.add_argument( - "-v", - "--verbosity", - action="store", - default=1, - type=int, - help="verbosity level, 1 - (ERROR, default), 2 - (INFO) or 3 - (DEBUG)", - choices=[1, 2, 3], - ) - _ = parser.add_argument( - "-p", - "--prefix", - action="store", - type=str, - help="Load only variables with given prefix", - ) - _ = parser.add_argument( - "-s", - "--strip-prefix", - action="store_true", - help="Strip prefix given with --prefix from environment variables names", - ) - _ = parser.add_argument( - "--dry-run", - action="store_true", - help="Return parsed .env instead of running command", - ) - - args, argv = parser.parse_known_args(argv) - - add_stdout_handler(int(args.verbosity)) - logger.debug("args: %s", args) - - loaded_env = create_env(args.env_file, prefix=args.prefix, strip_prefix=args.strip_prefix) - loaded_env["_RUNENV_WRAPPED"] = "1" - - if args.dry_run: - sys.stdout.write("Dry run mode\n") - sys.stdout.write(f"Parsed environment: {loaded_env}\n") - sys.exit(0) - os.environ.update(loaded_env) - - cmd = args.command - - if not cmd.startswith(("/", ".")): - cmd = shutil.which(cmd) - - try: - if cmd is None or not (stat.S_IXUSR & os.stat(cmd)[stat.ST_MODE]): - _ = sys.stdout.write(f"File `{cmd} is not executable\n") - sys.exit(1) - return subprocess.check_call([cmd] + argv, env=os.environ) # noqa: RUF005, S603 - except subprocess.CalledProcessError as e: - return e.returncode - - -def resolve_lazy_value(value: str, env_vars: Dict[str, str]) -> str: - """ - Recursively resolve variable references (e.g., ${VAR_NAME}) in a value using env_vars. - """ - - def replace_match(match: re.Match[str]) -> str: - var_name = match.group(1) - # Resolve variable from env_vars or os.environ - return env_vars.get(var_name, os.environ.get(var_name, "")) - - # Replace all occurrences of ${VAR_NAME} in the value - return VARIABLE_REFERENCE_REGEX.sub(replace_match, value) - - -def create_env( - env_file: str = ".env", - prefix: Union[str, None] = None, - strip_prefix: bool = True, # noqa: FBT001,FBT002 -) -> Dict[str, str]: - """Create environ dictionary from current variables got from given `env_file`.""" - environ: Dict[str, str] = {} - with open(env_file) as f: - for raw_line in f: - line = raw_line.rstrip(os.linesep) - - # Strip leading and trailing whitespace from the line - line = line.strip() - - # Skip empty lines and comments - if not line or line.startswith("#"): - continue - - # Match key-value pairs (supports inline comments and empty values) - match = re.match(r'^\s*([\w.]+)\s*=\s*(?:"([^"]*)"|\'([^\']*)\'|([^\n#]*?))\s*(?:#.*)?$', line) - - if match: - key = match.group(1) - # Only one of groups 2, 3, or 4 will contain the value - value = next(g for g in match.groups()[1:] if g is not None) - - # skip not prefixed if prefix used - if prefix and key != prefix and not key.startswith(prefix): - logger.debug("skip %s without prefix %s", key, prefix) - continue - if prefix and key != prefix and strip_prefix: - logger.debug("strip %s without prefix %s", key, prefix) - key = key[len(prefix) :] - - # Strip quotes if they exist - if (value.startswith('"') and value.endswith('"')) or (value.startswith("'") and value.endswith("'")): - value = value[1:-1] - - environ[key] = value - else: - logger.debug("skip not matched %s", line) - - # Perform lazy evaluation after parsing all variables - for key, value in environ.items(): - environ[key] = resolve_lazy_value(value, environ) - - return environ - - -def load_env( - env_file: str = ".env", - prefix: Union[str, None] = None, - strip_prefix: bool = True, # noqa: FBT001,FBT002 - force: bool = False, # noqa: FBT001,FBT002 - search_parent: int = 0, -) -> None: - # we need absolute path to support `search_parent` - env_file = os.path.abspath(env_file) - logger.info("trying env file %s", env_file) - - if "_RUNENV_WRAPPED" in os.environ and not force: - return None - if not os.path.exists(env_file): - if not search_parent: - return None - env_file = os.path.join(os.path.dirname(os.path.dirname(env_file)), os.path.basename(env_file)) - return load_env(env_file, prefix, strip_prefix, force, search_parent - 1) - - os.environ.update(create_env(env_file, prefix=prefix, strip_prefix=strip_prefix)) - logger.info("env file %s loaded", env_file) - return None +__all__ = ["create_env", "load_env"] diff --git a/src/runenv/api.py b/src/runenv/api.py new file mode 100644 index 0000000..12cdfd4 --- /dev/null +++ b/src/runenv/api.py @@ -0,0 +1,82 @@ +# SPDX-FileCopyrightText: 2015-present Marek Wywiał +# +# SPDX-License-Identifier: MIT +from __future__ import annotations + +import logging +import os +from pathlib import Path +from typing import Dict, List, Union + +from runenv.parser import ParseMessage, ParseOptions, lint_env_file, parse_env_file + +logger = logging.getLogger(__name__) + + +def find_env_file(path: Path, search_parent: int = 0, filename: Union[str, Path, None] = None) -> Union[Path, None]: + search_names: List[str] = [".env", ".env.json", ".env.toml", ".env.yaml"] + + names = [filename] if filename else search_names[:] + + for name in names: + logger.debug("Searching for %s files at %s with search_parent=%s", name, path, search_parent) + if (path / name).is_file(): + file = path / name + logger.debug("Found env file: %s", file) + return file + if search_parent > 0: + env_file = find_env_file(path.parent, search_parent - 1, filename=filename) + if env_file: + return env_file + return None + + +def create_env( + env_file: Union[str, Path, None] = None, + prefix: Union[str, None] = None, + strip_prefix: bool = True, # noqa: FBT001,FBT002 + search_parent: int = 0, +) -> Dict[str, str]: + """Create environ dictionary from current variables got from given `env_file`.""" + env_file = find_env_file(Path.cwd(), search_parent, filename=env_file) + if not env_file: + raise ValueError("No env file found") + return parse_env_file(env_file, ParseOptions(prefix=prefix, strip_prefix=strip_prefix)) + + +def load_env( + env_file: Union[str, Path, None] = None, + prefix: Union[str, None] = None, + strip_prefix: bool = True, # noqa: FBT001,FBT002 + force: bool = False, # noqa: FBT001,FBT002 + search_parent: int = 0, + require_env_file: bool = False, +) -> None: + + env_file = find_env_file(Path.cwd(), search_parent, filename=env_file) + + # In `load_env` we will not fail if file does not exists + if not env_file: + if require_env_file: + raise ValueError("No env file found") + return + + if "_RUNENV_WRAPPED" in os.environ and not force: + return + + os.environ.update(create_env(env_file, prefix=prefix, strip_prefix=strip_prefix)) + logger.info("env file %s loaded", getattr(env_file, "name", str(env_file))) + return + + +def lint_env( + env_file: Union[str, Path, None] = None, + prefix: Union[str, None] = None, + strip_prefix: bool = True, # noqa: FBT001,FBT002 + search_parent: int = 0, +) -> List[ParseMessage]: + """Lint env_file.""" + env_file = find_env_file(Path.cwd(), search_parent, filename=env_file) + if not env_file: + raise ValueError("No env file found") + return lint_env_file(env_file, ParseOptions(prefix=prefix, strip_prefix=strip_prefix)) diff --git a/src/runenv/cli.py b/src/runenv/cli.py new file mode 100644 index 0000000..884337b --- /dev/null +++ b/src/runenv/cli.py @@ -0,0 +1,311 @@ +# SPDX-FileCopyrightText: 2015-present Marek Wywiał +# +# SPDX-License-Identifier: MIT +from __future__ import annotations + +import argparse +import json +import logging +import os +import shutil +import stat +import subprocess +import sys +from dataclasses import asdict, dataclass +from pathlib import Path +from textwrap import dedent +from typing import List, Optional, Sequence, Union, cast + +from runenv.__about__ import __version__ +from runenv.api import create_env, find_env_file, lint_env +from runenv.legacy import run_legacy, run_legacy_parser + +logger = logging.getLogger(__name__) + + +def add_stdout_handler(verbosity: int) -> None: + """Adds stdout handler with given verbosity to logger. + + Args: + logger: python logger instance + verbosity: target verbosity + 1 - ERROR + 2 - INFO + 3 - DEBUG + + Usage: + add_stdout_handler(verbosity=3) + + """ + v_map = {1: logging.ERROR, 2: logging.INFO, 3: logging.DEBUG} + level = v_map.get(verbosity, 1) + logging.basicConfig(level=level) + + +@dataclass +class CLIOptions: + verbosity: int + + +@dataclass +class RunCMDOptions(CLIOptions): + env_file: str + prefix: Union[str, None] + strip_prefix: bool + search_parent: int + command: List[str] + + +@dataclass +class ListCMDOptions(CLIOptions): + env_file: str + prefix: Union[str, None] + strip_prefix: bool + search_parent: int + + +@dataclass +class LintCMDOptions(CLIOptions): + env_file: str + prefix: Union[str, None] + strip_prefix: bool + search_parent: int + as_json: bool + + +def fail(msg: str, returncode: int = 1) -> None: + sys.stdout.write(f"{msg}\n") + sys.exit(returncode) + + +def handle_run_subcommand(options: RunCMDOptions) -> Union[int, None]: + cmd = options.command[1:] if options.command and options.command[0] == "--" else options.command[:] + if not cmd: + sys.stdout.write("Missing command to execute after 'runenv run -- [params]'\n") + sys.exit(1) + + loaded_env = create_env( + options.env_file, prefix=options.prefix, strip_prefix=options.strip_prefix, search_parent=options.search_parent + ) + loaded_env["_RUNENV_WRAPPED"] = "1" + os.environ.update(loaded_env) + + executable = shutil.which(cmd[0]) + params = cmd[1:] + + try: + if executable is None or not os.path.exists(executable): + fail(f"File `{executable} does not exist", 1) + return 1 + if not (os.stat(executable).st_mode & stat.S_IXUSR): + fail(f"File `{executable} is not executable") + return 1 + return subprocess.check_call([executable, *params], env=os.environ) # noqa: S603 + except subprocess.CalledProcessError as e: + return e.returncode + + +def handle_list_subcommand(options: ListCMDOptions) -> None: + loaded_env = create_env( + options.env_file, prefix=options.prefix, strip_prefix=options.strip_prefix, search_parent=options.search_parent + ) + for key, value in sorted(loaded_env.items()): + sys.stdout.write(f"{key}={value}\n") + + +def handle_lint_subcommand(options: LintCMDOptions) -> None: + messages = lint_env( + options.env_file, prefix=options.prefix, strip_prefix=options.strip_prefix, search_parent=options.search_parent + ) + if options.as_json: + sys.stdout.write(json.dumps([asdict(m) for m in messages])) + else: + for msg in messages: + sys.stdout.write(f"[{msg.level}] (line {msg.line_number}) '{msg.message}'\n") + + +def run(argv: Optional[Sequence[str]] = None) -> Union[int, None]: + """Run CLI. + + Args: + argv: list of CLI arguments + """ + if argv is None: + argv = sys.argv[1:] + # do not pass `-h` | `--help` to legacy + params = (" ".join(argv).split(" -- ", 1))[0].split(" ") + if "-h" not in params and "--help" not in params: + _, l_argv = run_legacy_parser(argv, only_params=True) + if len(l_argv) > 0 and Path(l_argv[0]).is_file(): + # Legacy usage detected + return run_legacy() + + prog = "runenv" + description = "Run program with given environment file loaded" + epilog = dedent( + """ + NOTES: + v1.3.0: + The `runenv .env ` still works but the new separate subcommand as introduced: + + $ runenv run [--env-file .env] -- command --with --params + + """ + ) + + parser = argparse.ArgumentParser( + prog=prog, description=description, epilog=epilog, formatter_class=argparse.RawTextHelpFormatter + ) + parser.add_argument("-V", "--version", action="version", version=f"%(prog)s {__version__}") + parser.add_argument( + "-v", + "--verbosity", + action="store", + default=1, + type=int, + help="verbosity level, 1 - (ERROR, default), 2 - (INFO) or 3 - (DEBUG)", + choices=[1, 2, 3], + ) + parser.add_argument( + "--help-legacy", + action="store_true", + help="Return help from legacy `runenv .env [params]` CLI", + ) + + subparsers = parser.add_subparsers(dest="subcommand", required=False) + + # --- run command --- + run_parser = subparsers.add_parser("run", help="Run a command with .env loaded") + run_parser.add_argument("command", help="Command to run with loaded environment", nargs=argparse.REMAINDER) + run_parser.add_argument( + "--env-file", + help="Environment file to load", + type=str, + ) + run_parser.add_argument( + "-p", + "--prefix", + action="store", + type=str, + help="Load only variables with given prefix", + ) + run_parser.add_argument( + "-s", + "--strip-prefix", + action="store_true", + help="Strip prefix given with --prefix from environment variables names", + ) + run_parser.add_argument( + "--search-parent", + type=int, + default=0, + help="How many parent dirs search for .env[.json,.toml,.yaml] files; default 0", + ) + + # --- list command --- + list_parser = subparsers.add_parser("list", help="List parsed variables") + list_parser.add_argument( + "--env-file", + help="Environment file to load", + type=str, + ) + list_parser.add_argument( + "-p", + "--prefix", + action="store", + type=str, + help="Load only variables with given prefix", + ) + list_parser.add_argument( + "-s", + "--strip-prefix", + action="store_true", + help="Strip prefix given with --prefix from environment variables names", + ) + list_parser.add_argument( + "--search-parent", + type=int, + default=0, + help="How many parent dirs search for .env[.json,.toml,.yaml] files; default 0", + ) + + # --- lint command --- + lint_parser = subparsers.add_parser("lint", help="Lint env file") + lint_parser.add_argument( + "--env-file", + help="Environment file to lint", + type=str, + ) + lint_parser.add_argument( + "-p", + "--prefix", + action="store", + type=str, + help="Load only variables with given prefix", + ) + lint_parser.add_argument( + "-s", + "--strip-prefix", + action="store_true", + help="Strip prefix given with --prefix from environment variables names", + ) + lint_parser.add_argument( + "--search-parent", + type=int, + default=0, + help="How many parent dirs search for .env[.json,.toml,.yaml] files; default 0", + ) + lint_parser.add_argument( + "--as-json", + action="store_true", + help="Return json instead log lines", + ) + + args = parser.parse_args(argv) + + add_stdout_handler(cast("int", args.verbosity)) + + logger.debug("args: %s", args) + subcommand: str = args.subcommand + + if subcommand == "run": + handler = handle_run_subcommand + env_file = find_env_file(Path.cwd(), args.search_parent, args.env_file) + if not env_file: + fail(f"ERROR!!! Environment file {args.env_file} does not exist", 1) + opts = RunCMDOptions( + verbosity=args.verbosity, + env_file=args.env_file, + prefix=args.prefix, + strip_prefix=args.strip_prefix, + search_parent=args.search_parent, + command=args.command, + ) + elif subcommand == "list": + handler = handle_list_subcommand + env_file = find_env_file(Path.cwd(), args.search_parent, args.env_file) + if not env_file: + fail(f"ERROR!!! Environment file {args.env_file} does not exist", 1) + opts = ListCMDOptions( + verbosity=args.verbosity, + env_file=args.env_file, + prefix=args.prefix, + strip_prefix=args.strip_prefix, + search_parent=args.search_parent, + ) + elif subcommand == "lint": + handler = handle_lint_subcommand + env_file = find_env_file(Path.cwd(), args.search_parent, args.env_file) + if not env_file: + fail(f"ERROR!!! Environment file {args.env_file} does not exist", 1) + opts = LintCMDOptions( + verbosity=args.verbosity, + env_file=args.env_file, + prefix=args.prefix, + strip_prefix=args.strip_prefix, + search_parent=args.search_parent, + as_json=args.as_json, + ) + else: + parser.error("Unknown subcommand") + return handler(opts) diff --git a/src/runenv/legacy.py b/src/runenv/legacy.py new file mode 100644 index 0000000..007335b --- /dev/null +++ b/src/runenv/legacy.py @@ -0,0 +1,120 @@ +# SPDX-FileCopyrightText: 2015-present Marek Wywiał +# +# SPDX-License-Identifier: MIT +from __future__ import annotations + +import argparse +import logging +import os +import shutil +import stat +import subprocess +import sys +from typing import List, Optional, Sequence, Tuple + +from runenv.__about__ import __version__ +from runenv.api import create_env + +logger = logging.getLogger(__name__) + + +def add_stdout_handler(verbosity: int) -> None: + """Adds stdout handler with given verbosity to logger. + + Args: + logger: python logger instance + verbosity: target verbosity + 1 - ERROR + 2 - INFO + 3 - DEBUG + + Usage: + add_stdout_handler(verbosity=3) + + """ + v_map = {1: logging.ERROR, 2: logging.INFO, 3: logging.DEBUG} + level = v_map.get(verbosity, 1) + logging.basicConfig(level=level) + + +def run_legacy_parser( + argv: Optional[Sequence[str]], + only_params: bool = False, # noqa: FBT001,FBT002 +) -> Tuple[argparse.Namespace, List[str]]: + prog = "runenv" + description = "Run program with given environment file loaded" + + parser = argparse.ArgumentParser(prog=prog, description=description) + + if not only_params: + parser.add_argument("env_file", help="Environment file to load") + parser.add_argument("command", help="Command to run with loaded environment") + + parser.add_argument("-V", "--version", action="version", version=f"%(prog)s {__version__}") + parser.add_argument( + "-v", + "--verbosity", + action="store", + default=1, + type=int, + help="verbosity level, 1 - (ERROR, default), 2 - (INFO) or 3 - (DEBUG)", + choices=[1, 2, 3], + ) + parser.add_argument( + "-p", + "--prefix", + action="store", + type=str, + help="Load only variables with given prefix", + ) + parser.add_argument( + "-s", + "--strip-prefix", + action="store_true", + help="Strip prefix given with --prefix from environment variables names", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Return parsed .env instead of running command", + ) + + return parser.parse_known_args(argv) + + +def run_legacy(argv: Optional[Sequence[str]] = None) -> int: + """Run CLI. + + Args: + argv: list of CLI arguments + """ + logger.debug("[DEPRECATED] Use 'runenv run --env-file .env command' instead.") + args, argv = run_legacy_parser(argv) + + add_stdout_handler(int(args.verbosity)) + logger.debug("args: %s", args) + + loaded_env = create_env(args.env_file, prefix=args.prefix, strip_prefix=args.strip_prefix) + loaded_env["_RUNENV_WRAPPED"] = "1" + + if args.dry_run: + sys.stdout.write("[legacy] Dry run mode\n") + sys.stdout.write(f"[legacy] Parsed environment: {loaded_env}\n") + sys.exit(0) + os.environ.update(loaded_env) + + cmd = args.command + + if not cmd.startswith(("/", ".")): + cmd = shutil.which(cmd) + + try: + if cmd is None or not os.path.exists(cmd): + sys.stdout.write(f"[legacy] File `{args.command} does not exist\n") + sys.exit(1) + if not (stat.S_IXUSR & os.stat(cmd)[stat.ST_MODE]): + sys.stdout.write(f"[legacy] File `{args.cmd} is not executable\n") + sys.exit(1) + return subprocess.check_call([cmd] + argv, env=os.environ) # noqa: RUF005, S603 + except subprocess.CalledProcessError as e: + return e.returncode diff --git a/src/runenv/parser.py b/src/runenv/parser.py new file mode 100644 index 0000000..c776ce1 --- /dev/null +++ b/src/runenv/parser.py @@ -0,0 +1,189 @@ +from __future__ import annotations + +import json +import logging +import os +import re +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, List, Tuple, Union + +logger = logging.getLogger(__name__) + +# Regular expression to match variable references like ${VAR_NAME} +VARIABLE_LINE_REGEX = re.compile(r'^\s*([\w.]+)\s*=\s*(?:"([^"]*)"|\'([^\']*)\'|([^\n#]*?))\s*(?:#.*)?$') +VARIABLE_REFERENCE_REGEX = re.compile(r"\$\{(\w+)\}") + + +@dataclass +class ParseOptions: + prefix: Union[str, None] = None + strip_prefix: bool = True + + +@dataclass +class ParseMessage: + line_number: int + level: str + message: str + + +class EnvParser: + def __init__(self, options: ParseOptions) -> None: + self.options: ParseOptions = options + self.raw_environ: Dict[str, str] = {} + self.final_environ: Dict[str, str] = {} + self.messages: List[ParseMessage] = [] + + def parse(self, env_file: Union[str, Path]) -> EnvParser: + filename = env_file if isinstance(env_file, str) else env_file.name + extension = Path(filename).suffix + + LOADERS = { + ".json": self.load_json_file, + ".yaml": self.load_yaml_file, + ".toml": self.load_toml_file, + } + loader = LOADERS.get(extension, self.load_env_file) + environ = loader(env_file) + # skip not prefixed if prefix used + for line_number, key, value in environ: + if self.options.prefix and key != self.options.prefix and not key.startswith(self.options.prefix): + + msg = f"skip {key} without prefix {self.options.prefix}" + logger.debug(msg) + self.messages.append( + ParseMessage( + line_number=line_number, + level="info", + message=msg, + ) + ) + continue + if self.options.prefix and key != self.options.prefix and self.options.strip_prefix: + logger.debug("strip %s without prefix %s", key, self.options.prefix) + key = key[len(self.options.prefix) :] + + if key in self.raw_environ: + msg = f"duplicated '{key}' variable" + logger.debug(msg) + self.messages.append( + ParseMessage( + line_number=line_number, + level="error", + message=msg, + ) + ) + self.raw_environ[key] = value + + # Perform lazy evaluation after parsing all variables + for key, value in self.raw_environ.items(): + self.final_environ[key] = substitute_variables(value, self.raw_environ) + return self + + def load_env_file(self, env_file: Union[str, Path]) -> List[Tuple[int, str, str]]: + environ: List[Tuple[int, str, str]] = [] + with open(env_file) as f: + + for line_number, raw_line in enumerate(f, start=1): + line = raw_line.rstrip(os.linesep) + + # Strip leading and trailing whitespace from the line + line = line.strip() + + # Skip empty lines and comments + if not line or line.startswith("#"): + continue + + # Match key-value pairs (supports inline comments and empty values) + match = re.match(VARIABLE_LINE_REGEX, line) + + if match: + key = match.group(1) + # Only one of groups 2, 3, or 4 will contain the value + value = next(g for g in match.groups()[1:] if g is not None) + + # Strip quotes if they exist + if (value.startswith('"') and value.endswith('"')) or ( + value.startswith("'") and value.endswith("'") + ): + value = value[1:-1] + + environ.append((line_number, key, value)) + + else: + msg = "line not matched" + logger.debug("%s '%s'", msg, line) + self.messages.append( + ParseMessage( + line_number=line_number, + level="warning", + message=msg, + ) + ) + + return environ + + def load_json_file(self, env_file: Union[str, Path]) -> List[Tuple[int, str, str]]: + environ: List[Tuple[int, str, str]] = [] + with open(env_file) as f: + line_number = 1 + for key, value in json.loads(f.read()).items(): + environ.append((line_number, key, value)) + line_number += 1 + return environ + + def load_yaml_file(self, env_file: Union[str, Path]) -> List[Tuple[int, str, str]]: + try: + import yaml + except ImportError: + sys.stderr.write("ERROR!!! To use YAML install runenv[yaml]\n") + sys.exit(1) + environ: List[Tuple[int, str, str]] = [] + with open(env_file, "r") as f: + line_number = 1 + for key, value in yaml.safe_load(f.read()).items(): + environ.append((line_number, key, value)) + line_number += 1 + return environ + + def load_toml_file(self, env_file: Union[str, Path]) -> List[Tuple[int, str, str]]: + if sys.version_info >= (3, 11): + import tomllib as tomli + else: + try: + import tomli + except ImportError: + sys.stderr.write("ERROR!!! To use YAML install runenv[toml]\n") + sys.exit(1) + environ: List[Tuple[int, str, str]] = [] + with open(env_file, "rb") as f: + line_number = 1 + for key, value in tomli.load(f).items(): + environ.append((line_number, key, value)) + line_number += 1 + return environ + + +def substitute_variables(value: str, env_vars: Dict[str, str]) -> str: + """ + Recursively resolve variable references (e.g., ${VAR_NAME}) in a value using env_vars. + """ + + def replace_match(match: re.Match[str]) -> str: + var_name = match.group(1) + # Resolve variable from env_vars or os.environ + return str(env_vars.get(var_name, os.environ.get(var_name, ""))) + + # Replace all occurrences of ${VAR_NAME} in the value + logger.debug(f"VALUE: {value} , type {type(value)}") + return VARIABLE_REFERENCE_REGEX.sub(replace_match, str(value)) + + +def parse_env_file(env_file: Union[str, Path], options: ParseOptions) -> Dict[str, str]: + return EnvParser(options).parse(env_file).final_environ + + +def lint_env_file(env_file: Union[str, Path], options: ParseOptions) -> List[ParseMessage]: + return EnvParser(options).parse(env_file).messages diff --git a/tests/test_runenv.py b/tests/test_api.py old mode 100755 new mode 100644 similarity index 75% rename from tests/test_runenv.py rename to tests/test_api.py index 5822dfe..2b266b6 --- a/tests/test_runenv.py +++ b/tests/test_api.py @@ -1,65 +1,18 @@ -#!/usr/bin/env python -""" -test_runenv. ----------------------------------- - -Tests for `runenv` module. -""" - import os -import sys -import unittest -from contextlib import contextmanager -from io import StringIO - -import pytest +from unittest import mock -from runenv import create_env, load_env, run +from runenv import create_env, load_env from . import TESTS_DIR -@contextmanager -def capture(command, *args, **kwargs): - out, sys.stdout = sys.stdout, StringIO() - command(*args, **kwargs) - sys.stdout.seek(0) - yield sys.stdout.read() - sys.stdout = out - - -class TestRunenv(unittest.TestCase): - def setUp(self) -> None: - self.env_file = os.path.join(TESTS_DIR, "env.test") - - def tearDown(self) -> None: - variables = ( - "VARIABLED", - "STRING", - "NUMBER", - "FLOAT", - "EMPTY", - "SPACED", - "COMMENTED", - "RUNENV_STRING", - "RUNENV_NUMBER", - "RUNENV_FLOAT", - "RUNENVC_STRING", - "RUNENVC_NUMBER", - "RUNENVC_FLOAT", - "_RUNENV_WRAPPED", - "SINGLE_QUOTE", - "DOUBLE_QUOTE", - "DOUBLE_QUOTE_WITH_COMMENT", - ) - for k in variables: - if k in os.environ: - del os.environ[k] +class TestApi: + @mock.patch.dict(os.environ, {}, clear=True) def test_create_env(self) -> None: os.environ["ALREADY_SET"] = "YES" - environ = create_env(self.env_file) + environ = create_env(os.path.join(TESTS_DIR, "env.test")) assert environ.get("VARIABLED") == "some_lazy_variable_12" assert environ.get("STRING") == "some string with spaces" assert environ.get("NUMBER") == "12" @@ -81,22 +34,7 @@ def test_create_env(self) -> None: # but is loaded into our interpolation assert environ.get("FROM_ENV") == "MAYBE-YES" - @pytest.mark.skipif( - "linux" not in sys.platform, - reason="works on linux", - ) - def test_run(self) -> None: - assert run([self.env_file, "/bin/true"]) == 0 - assert run([self.env_file, "/bin/false"]) == 1 - with capture(run, [self.env_file, "/usr/bin/env"]) as output: - assert "_RUNENV_WRAPPED", output - - def test_run_from_path(self) -> None: - assert run([self.env_file, "true"]) == 0 - assert run([self.env_file, "false"]) == 1 - with capture(run, [self.env_file, "env"]) as output: - assert "_RUNENV_WRAPPED", output - + @mock.patch.dict(os.environ, {}, clear=True) def test_load_env_from_default_file(self) -> None: os.chdir(os.path.join(TESTS_DIR, "cwd")) @@ -113,6 +51,7 @@ def test_load_env_from_default_file(self) -> None: assert os.environ.get("RUNENV_NUMBER") == "13" assert os.environ.get("RUNENV_FLOAT") == "12.12" + @mock.patch.dict(os.environ, {}, clear=True) def test_load_env_only_prefixed_variables(self) -> None: os.chdir(os.path.join(TESTS_DIR, "cwd")) @@ -127,6 +66,7 @@ def test_load_env_only_prefixed_variables(self) -> None: assert "RUNENVC_NUMBER" not in os.environ assert "RUNENVC_FLOAT" not in os.environ + @mock.patch.dict(os.environ, {}, clear=True) def test_load_env_only_prefixed_variables_without_strip_prefix(self) -> None: os.chdir(os.path.join(TESTS_DIR, "cwd")) @@ -140,6 +80,7 @@ def test_load_env_only_prefixed_variables_without_strip_prefix(self) -> None: assert "RUNENVC_NUMBER" not in os.environ assert "RUNENVC_FLOAT" not in os.environ + @mock.patch.dict(os.environ, {}, clear=True) def test_load_env_from_custom_file(self) -> None: os.chdir(os.path.join(TESTS_DIR, "cwd")) @@ -156,6 +97,7 @@ def test_load_env_from_custom_file(self) -> None: assert os.environ.get("RUNENVC_NUMBER") == "14" assert os.environ.get("RUNENVC_FLOAT") == "14.14" + @mock.patch.dict(os.environ, {}, clear=True) def test_load_env_skip_if_wrapped_by_runenv(self) -> None: os.chdir(os.path.join(TESTS_DIR, "cwd")) @@ -172,6 +114,7 @@ def test_load_env_skip_if_wrapped_by_runenv(self) -> None: assert "RUNENVC_NUMBER" not in os.environ assert "RUNENVC_FLOAT" not in os.environ + @mock.patch.dict(os.environ, {}, clear=True) def test_load_env_force_even_wrapped_by_runenv(self) -> None: os.chdir(os.path.join(TESTS_DIR, "cwd")) @@ -190,9 +133,11 @@ def test_load_env_force_even_wrapped_by_runenv(self) -> None: assert os.environ.get("RUNENVC_NUMBER") == "14" assert os.environ.get("RUNENVC_FLOAT") == "14.14" + @mock.patch.dict(os.environ, {}, clear=True) def test_load_env_from_missing_file(self) -> None: load_env(env_file="env.missing") + @mock.patch.dict(os.environ, {}, clear=True) def test_search_parent(self) -> None: env_file = "env.search_parent" os.chdir(os.path.join(TESTS_DIR, "search_parent", "project")) @@ -211,6 +156,7 @@ def test_search_parent(self) -> None: assert "PARENT" in os.environ assert os.environ.get("PARENT") == "2" + @mock.patch.dict(os.environ, {}, clear=True) def test_search_grand_parent(self) -> None: env_file = "env.search_grandparent" os.chdir(os.path.join(TESTS_DIR, "search_grandparent", "project")) @@ -227,7 +173,3 @@ def test_search_grand_parent(self) -> None: load_env(env_file=env_file, search_parent=2) assert "GRAND_PARENT" in os.environ assert os.environ.get("GRAND_PARENT") == "3" - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..58b6281 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,64 @@ +import os +import sys +from textwrap import dedent + +import pytest + +from runenv.cli import run + +from . import TESTS_DIR + +TEST_FILE = os.path.join(TESTS_DIR, "env.test") + + +def test_list_shows_env(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None: + monkeypatch.setenv("ALREADY_SET", "external-var") + run(["list", "--env-file", TEST_FILE]) + out = capsys.readouterr().out + + assert ( + out.strip() + == dedent( + """\ + DOUBLE_QUOTE=so'me + DOUBLE_QUOTED_WITH_HASH=some#one + DOUBLE_QUOTE_WITH_COMMENT=so'me either + EMPTY= + FLOAT=11.11 + FROM_ENV=MAYBE-external-var + NUMBER=12 + QUOTED_WITH_HASH=some#one + SINGLE_QUOTE=so"me + SPACED= spaced + STRING=some string with spaces + VARIABLED=some_lazy_variable_12 + """ + ).strip() + ) + + +def test_run_sets_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("ALREADY_SET", "external-var-run") + run(["run", "--env-file", TEST_FILE, sys.executable]) + + assert os.environ.get("_RUNENV_WRAPPED") == "1" + + # check variables + assert os.environ.get("VARIABLED") == "some_lazy_variable_12" + assert os.environ.get("STRING") == "some string with spaces" + assert os.environ.get("NUMBER") == "12" + assert os.environ.get("FLOAT") == "11.11" + assert os.environ.get("EMPTY") == "" + assert os.environ.get("SPACED") == " spaced" + assert os.environ.get("SINGLE_QUOTE") == 'so"me' + assert os.environ.get("DOUBLE_QUOTE") == "so'me" + assert os.environ.get("DOUBLE_QUOTE_WITH_COMMENT") == "so'me either" + + assert os.environ.get("QUOTED_WITH_HASH") == "some#one" + assert os.environ.get("DOUBLE_QUOTED_WITH_HASH") == "some#one" + + assert "COMMENTED" not in os.environ + assert "# COMMENTED" not in os.environ + + # external variable is loaded into our interpolation + assert os.environ.get("FROM_ENV") == "MAYBE-external-var-run" diff --git a/tests/test_legacy_cli.py b/tests/test_legacy_cli.py new file mode 100644 index 0000000..d45d409 --- /dev/null +++ b/tests/test_legacy_cli.py @@ -0,0 +1,27 @@ +import os +import sys + +import pytest + +from runenv.legacy import run_legacy + +from . import TESTS_DIR + +TEST_FILE = os.path.join(TESTS_DIR, "env.test") + + +class TestLegacyCli: + + @pytest.mark.skipif( + "linux" not in sys.platform, + reason="works on linux", + ) + def test_run(self, monkeypatch: pytest.MonkeyPatch) -> None: + assert run_legacy([TEST_FILE, "/bin/true"]) == 0 + assert run_legacy([TEST_FILE, "/bin/false"]) == 1 + assert os.environ.get("_RUNENV_WRAPPED") == "1" + + def test_run_from_path(self, monkeypatch: pytest.MonkeyPatch) -> None: + assert run_legacy([TEST_FILE, "true"]) == 0 + assert run_legacy([TEST_FILE, "false"]) == 1 + assert os.environ.get("_RUNENV_WRAPPED") == "1"