summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore2
-rw-r--r--.pylintrc16
-rw-r--r--LICENSE.md6
-rw-r--r--README.md38
-rw-r--r--dev-requirements.in3
-rw-r--r--dev-requirements.txt26
-rw-r--r--envdir/__init__.py0
-rw-r--r--envdir/cli.py113
-rw-r--r--setup.py21
-rwxr-xr-xtools/check10
-rwxr-xr-xtools/check-lint10
-rwxr-xr-xtools/check-style10
12 files changed, 255 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..66dbf51
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+*.egg-info
+*.pyc
diff --git a/.pylintrc b/.pylintrc
new file mode 100644
index 0000000..c590644
--- /dev/null
+++ b/.pylintrc
@@ -0,0 +1,16 @@
+[MASTER]
+# No lint failures allowed.
+fail-under=10.0
+
+# Prevent litter in ~/.pylint.d
+persistent=no
+
+[MESSAGES CONTROL]
+disable=
+ # None of the modules in this are designed for import. This is a tool,
+ # with no library attached.
+ missing-module-docstring,
+ # I fundamentally disagree with pylint's assertion that stdlib imports go
+ # before library imports. All libraries are created more or less equal;
+ # the stdlib merely happens to come with the language.
+ wrong-import-order,
diff --git a/LICENSE.md b/LICENSE.md
new file mode 100644
index 0000000..dbfbc85
--- /dev/null
+++ b/LICENSE.md
@@ -0,0 +1,6 @@
+# License
+
+(c) Owen Jacobson 2020. All rights reserved.
+
+You can freely install use this software, as described in README.md, for
+personal use only. For all other use or redistribution, contact me.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..a3bcd77
--- /dev/null
+++ b/README.md
@@ -0,0 +1,38 @@
+# Environment Directory Helper
+
+This program loads environment variables from files.
+
+The program was motivated by the pattern of configuring various tokens via
+environment variables. I found my shell profile increasingly littered with code
+of the form:
+
+ export SOME_TOKEN="$(< ~/.some_token)"
+
+I've replaced all of that with a single line:
+
+ eval "$(envdir-helper)"
+
+## Security
+
+As alluded to above, one of the use cases for this is env-specific tokens. These
+kinds of tokens deserve special care - not just with this program, but in
+general:
+
+* They should be in files readable only by the current user (`-rw-------`) or by
+ the current user and group (`-rw-r-----`), as appropriate;
+* They should be rotated regularly; and
+* They should only be set when in use.
+
+This program does relatively little to manage this directly. One approach that helps is to invoke `envdir-helper` from [`direnv`] or similar, instead of from your shell profile, and to store the actual tokens in a system such as [Vault] or in the [macOS Keychain] to avoid leaving them on disk. Program entries in the environment directory can retrieve data from outside sources.
+
+[`direnv`]: https://direnv.net/
+[Vault]: https://www.vaultproject.io/
+[macOS Keychain]: https://developer.apple.com/documentation/security/keychain_services/keychain_items/searching_for_keychain_items
+
+## Installation
+
+Some familiarity with Python is assumed, here:
+
+* Make a virtual environment;
+* `$VIRTUALENV/bin/pip install git+https://github.com/ojacobson/envdir-helper/#egg=envdir-helper`; and
+* Add its `bin` directory to `PATH` by other means, or invoke it by full path.
diff --git a/dev-requirements.in b/dev-requirements.in
new file mode 100644
index 0000000..60ced2c
--- /dev/null
+++ b/dev-requirements.in
@@ -0,0 +1,3 @@
+black
+pylint
+pip-tools
diff --git a/dev-requirements.txt b/dev-requirements.txt
new file mode 100644
index 0000000..a8f9e72
--- /dev/null
+++ b/dev-requirements.txt
@@ -0,0 +1,26 @@
+#
+# This file is autogenerated by pip-compile
+# To update, run:
+#
+# pip-compile dev-requirements.in
+#
+appdirs==1.4.4 # via black
+astroid==2.4.2 # via pylint
+black==20.8b1 # via -r dev-requirements.in
+click==7.1.2 # via black, pip-tools
+isort==5.6.4 # via pylint
+lazy-object-proxy==1.4.3 # via astroid
+mccabe==0.6.1 # via pylint
+mypy-extensions==0.4.3 # via black
+pathspec==0.8.1 # via black
+pip-tools==5.4.0 # via -r dev-requirements.in
+pylint==2.6.0 # via -r dev-requirements.in
+regex==2020.11.13 # via black
+six==1.15.0 # via astroid, pip-tools
+toml==0.10.2 # via black, pylint
+typed-ast==1.4.1 # via black
+typing-extensions==3.7.4.3 # via black
+wrapt==1.12.1 # via astroid
+
+# The following packages are considered to be unsafe in a requirements file:
+# pip
diff --git a/envdir/__init__.py b/envdir/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/envdir/__init__.py
diff --git a/envdir/cli.py b/envdir/cli.py
new file mode 100644
index 0000000..9f9db15
--- /dev/null
+++ b/envdir/cli.py
@@ -0,0 +1,113 @@
+import click
+import os
+import os.path as op
+import pathlib
+import shlex
+import stat
+import subprocess as sp
+
+
+@click.command()
+@click.pass_context
+@click.argument(
+ "envdir",
+ default=pathlib.Path.home() / ".envdir",
+ metavar="DIR",
+)
+def main(context, envdir):
+ r"""Load environment variables from DIR (or ~/.envdir).
+
+ For each non-directory entry in DIR, this will output a brief shell script
+ to set an environment with the same name. If the entry is an executable
+ program, it will be run (with the current environment) and its output will
+ be used as the value of the environment variable. Otherwise, it will be
+ read, and its content will be used. In either case, a single trailing
+ newline will be removed, if present.
+
+ This process skips any file which can't be run or read, as appropriate, and
+ outputs a warning on stderr.
+
+ The intended use case for this is in shell profiles, in the form:
+
+ eval "$(envdir-helper)"
+
+ The generated output is compatible with sh, and thus with bash and zsh.
+ """
+
+ def warn_on_skipped(path, reason):
+ """Log any skipped paths to stderr."""
+ stderr = click.get_text_stream("stderr")
+ click.echo(f"{context.info_name}: skipping {path}: {reason}", file=stderr)
+
+ for name, content in walk_entries(envdir, on_skipped=warn_on_skipped):
+ script = env_script(name, content)
+ click.echo(script)
+
+
+def env_script(name, content):
+ """Given a name and contents, generate a shell script that will set and
+ export the corresponding environment variable, with that content."""
+ # use sh-friendly syntax here: don't assume `export` can have assignment
+ # side effects, so don't use `export FOO=BAR`. `FOO=BAR; export FOO` is
+ # portable to all posix shells.
+ qname = shlex.quote(name)
+ qcontent = shlex.quote(content)
+ return f"{qname}={qcontent}; export {qname}"
+
+
+def walk_entries(envdir, on_skipped):
+ """Yields a name, value pair for each environment file in envdir.
+
+ The path for any skipped items (generally, failing or unrunnable programs,
+ or unreadable files) will be passed to the `on_skipped` callback, along with
+ the exception that caused it to be skipped.
+ """
+ for name in sorted(os.listdir(envdir)):
+ path = op.join(envdir, name)
+ try:
+ path_stat = os.stat(path)
+ if directory(path_stat):
+ continue
+
+ if executable(path_stat):
+ content = from_program(path)
+ else:
+ content = from_file(path)
+ except Exception as reason: # pylint: disable=broad-except
+ on_skipped(path, reason)
+ continue
+ if content.endswith("\n"):
+ content = content[:-1]
+ yield name, content
+
+
+def directory(item):
+ """True iff item is a stat_result representing a directory."""
+ return stat.S_ISDIR(item.st_mode)
+
+
+def executable(item):
+ """True iff item is a stat_result representing something executable.
+
+ This doesn't distinguish between dirs and files; both are considered
+ executable if any +x bit is set.
+ """
+ mode = stat.S_IMODE(item.st_mode)
+ mask = stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
+ return mode & mask != 0
+
+
+def from_program(path):
+ """Reads a program's complete stdout into a string."""
+ result = sp.run(
+ [path],
+ check=True,
+ stdout=sp.PIPE,
+ )
+ return result.stdout.decode("UTF-8")
+
+
+def from_file(path):
+ """Reads a file's complete content into a string."""
+ with open(path, "r") as file:
+ return file.read()
diff --git a/setup.py b/setup.py
new file mode 100644
index 0000000..0d4c69a
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,21 @@
+from setuptools import setup, find_packages
+
+setup(
+ name="envdir-helper",
+ version="0.0.0",
+
+ author="Owen Jacobson",
+ author_email="owen@grimoire.ca",
+
+ packages=find_packages(),
+
+ install_requires=[
+ "click ~= 7.1.0",
+ ],
+
+ entry_points={
+ "console_scripts": [
+ "envdir-helper=envdir.cli:main",
+ ],
+ },
+)
diff --git a/tools/check b/tools/check
new file mode 100755
index 0000000..269a23d
--- /dev/null
+++ b/tools/check
@@ -0,0 +1,10 @@
+#!/bin/bash -e
+
+cd "$(dirname "$0")/.."
+
+## tools/check
+##
+## Run checks on the project, exiting with a zero status if all checks pass.
+
+tools/check-lint
+tools/check-style
diff --git a/tools/check-lint b/tools/check-lint
new file mode 100755
index 0000000..eaebdf0
--- /dev/null
+++ b/tools/check-lint
@@ -0,0 +1,10 @@
+#!/bin/bash -e
+
+cd "$(dirname "$0")/.."
+
+## tools/check-lint
+##
+## Run lint checks on the project, exiting with a zero status if there are no lint
+## issues.
+
+exec pylint envdir
diff --git a/tools/check-style b/tools/check-style
new file mode 100755
index 0000000..adb2499
--- /dev/null
+++ b/tools/check-style
@@ -0,0 +1,10 @@
+#!/bin/bash -e
+
+cd "$(dirname "$0")/.."
+
+## tools/check-style
+##
+## Run style checks on the project, exiting with a zero status if there are no style
+## issues.
+
+exec black --check --diff envdir