envref 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
envref/__init__.py ADDED
@@ -0,0 +1,40 @@
1
+ """envref — a .env where any value can be a reference into a secret store.
2
+
3
+ ATTIO_KEY={{ op://vault/Attio/api_key }}
4
+ DB_PASSWORD={{ keychain://myapp/db }}
5
+ TLS_KEY={{ file://~/.ssh/server.pem }}
6
+ REGION=eu-north-1
7
+
8
+ >>> import envref
9
+ >>> envref.load(".env") # resolves every reference, exports the rest
10
+
11
+ The scheme of a reference picks the resolver. Four ship built in (`op`,
12
+ `keychain`, `file`, `env`); others register through the `envref.resolvers`
13
+ entry-point group.
14
+ """
15
+
16
+ from envref.core import (
17
+ EnvrefError,
18
+ Resolver,
19
+ ResolveError,
20
+ ResolverUnavailable,
21
+ UnknownScheme,
22
+ load,
23
+ resolve,
24
+ )
25
+ from envref.parse import Entry, parse
26
+
27
+ __version__ = "0.1.0"
28
+
29
+ __all__ = [
30
+ "Entry",
31
+ "EnvrefError",
32
+ "ResolveError",
33
+ "Resolver",
34
+ "ResolverUnavailable",
35
+ "UnknownScheme",
36
+ "__version__",
37
+ "load",
38
+ "parse",
39
+ "resolve",
40
+ ]
envref/cli.py ADDED
@@ -0,0 +1,166 @@
1
+ """`envref` command line: run a command with references resolved, export them,
2
+ check a file without printing values, inspect the backends."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import getpass
8
+ import json
9
+ import os
10
+ import shlex
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ from envref import __version__
15
+ from envref.core import EnvrefError, ResolveError, load, resolve
16
+ from envref.parse import parse_file
17
+
18
+
19
+ def _files(args: argparse.Namespace) -> list[str]:
20
+ return args.file or [".env"]
21
+
22
+
23
+ def cmd_run(args: argparse.Namespace) -> int:
24
+ if not args.command:
25
+ print("envref run: no command given (use `envref run -- cmd args`)", file=sys.stderr)
26
+ return 2
27
+ env = dict(os.environ)
28
+ load(*_files(args), override=args.override, environ=env)
29
+ os.execvpe(args.command[0], args.command, env)
30
+ return 0 # unreachable
31
+
32
+
33
+ def cmd_export(args: argparse.Namespace) -> int:
34
+ values = load(*_files(args), export=False)
35
+ if args.format == "json":
36
+ print(json.dumps(values, indent=2))
37
+ elif args.format == "dotenv":
38
+ for name, value in values.items():
39
+ print(f"{name}={json.dumps(value)}")
40
+ else:
41
+ for name, value in values.items():
42
+ print(f"export {name}={shlex.quote(value)}")
43
+ return 0
44
+
45
+
46
+ def cmd_check(args: argparse.Namespace) -> int:
47
+ """Resolve everything and report per key, never printing a value."""
48
+ from envref.resolvers import registry
49
+
50
+ resolvers = registry()
51
+ failed = False
52
+ for file in _files(args):
53
+ path = Path(file).expanduser()
54
+ if not path.exists():
55
+ print(f"{file}: not found")
56
+ failed = True
57
+ continue
58
+ entries = parse_file(path)
59
+ print(f"{file}: {len(entries)} entries")
60
+ try:
61
+ resolve(entries, registry=resolvers)
62
+ failures: dict[str, str] = {}
63
+ except ResolveError as exc:
64
+ failures = exc.failures
65
+ failed = True
66
+ except EnvrefError as exc:
67
+ print(f" error: {exc}")
68
+ failed = True
69
+ continue
70
+ for entry in entries:
71
+ if entry.ref is None or entry.scheme not in resolvers:
72
+ kind = "literal"
73
+ mark = "·"
74
+ else:
75
+ kind = f"{entry.scheme}://"
76
+ mark = "✗" if entry.ref in failures else "✓"
77
+ line = f" {mark} {entry.name:<40} {kind}"
78
+ if entry.ref in failures:
79
+ line += f" {failures[entry.ref]}"
80
+ print(line)
81
+ return 1 if failed else 0
82
+
83
+
84
+ def cmd_doctor(args: argparse.Namespace) -> int:
85
+ from envref.resolvers import registry
86
+
87
+ print(f"envref {__version__}")
88
+ for scheme, resolver in sorted(registry().items()):
89
+ status = resolver.doctor() if hasattr(resolver, "doctor") else "ok"
90
+ print(f" {scheme + '://':<12} {type(resolver).__module__}.{type(resolver).__name__} {status}")
91
+ return 0
92
+
93
+
94
+ def cmd_op_save_password(args: argparse.Namespace) -> int:
95
+ from envref.resolvers import op
96
+
97
+ account = args.account or op.resolve_account()
98
+ if not account:
99
+ print("no 1Password account found; pass --account <shorthand>", file=sys.stderr)
100
+ return 1
101
+ password = getpass.getpass(f"1Password account password for {account}: ")
102
+ if not password:
103
+ return 1
104
+ service, name = op.save_password(account, password)
105
+ print(f"Stored in Keychain as service={service} account={name}.")
106
+ print("envref will read it behind Touch ID when a session needs renewing.")
107
+ return 0
108
+
109
+
110
+ def build_parser() -> argparse.ArgumentParser:
111
+ parser = argparse.ArgumentParser(
112
+ prog="envref",
113
+ description="A .env where any value can be a reference into a secret store.",
114
+ )
115
+ parser.add_argument("--version", action="version", version=f"envref {__version__}")
116
+ sub = parser.add_subparsers(dest="cmd", required=True)
117
+
118
+ def add_files(p: argparse.ArgumentParser) -> None:
119
+ p.add_argument(
120
+ "-f", "--file", action="append", metavar="FILE",
121
+ help="env file to load; repeat for several, first to set a name wins (default .env)",
122
+ )
123
+
124
+ run = sub.add_parser("run", help="run a command with the resolved environment")
125
+ add_files(run)
126
+ run.add_argument("--override", action="store_true", help="let the file override existing variables")
127
+ run.add_argument("command", nargs=argparse.REMAINDER, help="command and arguments, after --")
128
+ run.set_defaults(func=cmd_run)
129
+
130
+ export = sub.add_parser("export", help="print the resolved variables")
131
+ add_files(export)
132
+ export.add_argument("--format", choices=["shell", "dotenv", "json"], default="shell")
133
+ export.set_defaults(func=cmd_export)
134
+
135
+ check = sub.add_parser("check", help="resolve every reference and report, without printing values")
136
+ add_files(check)
137
+ check.set_defaults(func=cmd_check)
138
+
139
+ doctor = sub.add_parser("doctor", help="list installed resolvers and whether each can run here")
140
+ doctor.set_defaults(func=cmd_doctor)
141
+
142
+ op = sub.add_parser("op", help="1Password helpers")
143
+ op_sub = op.add_subparsers(dest="op_cmd", required=True)
144
+ save = op_sub.add_parser(
145
+ "save-password", help="store the account password in macOS Keychain for Touch ID sign-in"
146
+ )
147
+ save.add_argument("--account", help="account shorthand (default: the only signed-in account)")
148
+ save.set_defaults(func=cmd_op_save_password)
149
+
150
+ return parser
151
+
152
+
153
+ def main(argv: list[str] | None = None) -> None:
154
+ args = build_parser().parse_args(argv)
155
+ if getattr(args, "command", None) and args.command[0] == "--":
156
+ args.command = args.command[1:]
157
+ try:
158
+ code = args.func(args)
159
+ except EnvrefError as exc:
160
+ print(f"envref: {exc}", file=sys.stderr)
161
+ code = 1
162
+ sys.exit(code)
163
+
164
+
165
+ if __name__ == "__main__":
166
+ main()
envref/core.py ADDED
@@ -0,0 +1,135 @@
1
+ """Resolve parsed entries through the resolver each scheme names, and export."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from collections.abc import Iterable, Mapping, Sequence
7
+ from pathlib import Path
8
+ from typing import Protocol, runtime_checkable
9
+
10
+ from envref.parse import Entry, parse_file
11
+
12
+
13
+ class EnvrefError(Exception):
14
+ """Base class; everything envref raises on purpose derives from it."""
15
+
16
+
17
+ class UnknownScheme(EnvrefError):
18
+ """A `{{ scheme://… }}` reference whose scheme has no resolver installed."""
19
+
20
+
21
+ class ResolverUnavailable(EnvrefError):
22
+ """The resolver exists but cannot run here: no `op` binary, not macOS, no token."""
23
+
24
+
25
+ class ResolveError(EnvrefError):
26
+ """One or more references could not be resolved. `failures` maps ref → reason."""
27
+
28
+ def __init__(self, failures: Mapping[str, str], hint: str = "") -> None:
29
+ self.failures = dict(failures)
30
+ lines = [f" {ref}: {reason}" for ref, reason in sorted(self.failures.items())]
31
+ message = "could not resolve:\n" + "\n".join(lines)
32
+ if hint:
33
+ message += f"\n{hint}"
34
+ super().__init__(message)
35
+
36
+
37
+ @runtime_checkable
38
+ class Resolver(Protocol):
39
+ """What a scheme's backend provides. `resolve` gets every reference of its
40
+ scheme in one call so backends can batch (one round trip, one unlock)."""
41
+
42
+ scheme: str
43
+
44
+ def resolve(self, refs: Sequence[str]) -> dict[str, str]:
45
+ """Map each reference to its value. Raise `ResolveError` for the ones
46
+ that fail, `ResolverUnavailable` when the backend cannot run at all."""
47
+ ...
48
+
49
+
50
+ def _registry(registry: Mapping[str, Resolver] | None) -> Mapping[str, Resolver]:
51
+ if registry is not None:
52
+ return registry
53
+ from envref.resolvers import registry as default_registry
54
+
55
+ return default_registry()
56
+
57
+
58
+ def resolve(
59
+ entries: Iterable[Entry], *, registry: Mapping[str, Resolver] | None = None
60
+ ) -> dict[str, str]:
61
+ """Return name → value for every entry, references resolved.
62
+
63
+ References are grouped by scheme and handed to that scheme's resolver in
64
+ one call. A bare (brace-less) reference whose scheme has no resolver is
65
+ kept as the literal it probably was; an explicit `{{ }}` one raises.
66
+ """
67
+ resolvers = _registry(registry)
68
+ entries = list(entries)
69
+
70
+ by_scheme: dict[str, list[str]] = {}
71
+ for entry in entries:
72
+ if entry.ref is None:
73
+ continue
74
+ scheme = entry.scheme or ""
75
+ if scheme not in resolvers:
76
+ if entry.explicit:
77
+ known = ", ".join(sorted(resolvers)) or "none"
78
+ raise UnknownScheme(
79
+ f"{entry.name} (line {entry.line}): no resolver for '{scheme}://'. "
80
+ f"Installed: {known}."
81
+ )
82
+ continue
83
+ by_scheme.setdefault(scheme, []).append(entry.ref)
84
+
85
+ values: dict[str, str] = {}
86
+ for scheme, refs in by_scheme.items():
87
+ unique = sorted(set(refs))
88
+ resolved = resolvers[scheme].resolve(unique)
89
+ missing = [ref for ref in unique if ref not in resolved]
90
+ if missing:
91
+ raise ResolveError({ref: "resolver returned no value" for ref in missing})
92
+ values.update(resolved)
93
+
94
+ result: dict[str, str] = {}
95
+ for entry in entries:
96
+ if entry.ref is not None and entry.ref in values:
97
+ result[entry.name] = values[entry.ref]
98
+ else:
99
+ result[entry.name] = entry.value
100
+ return result
101
+
102
+
103
+ def load(
104
+ *paths: str | Path,
105
+ export: bool = True,
106
+ override: bool = False,
107
+ registry: Mapping[str, Resolver] | None = None,
108
+ environ: dict[str, str] | None = None,
109
+ ) -> dict[str, str]:
110
+ """Resolve one or more env files and, by default, export into `os.environ`.
111
+
112
+ Files are read in order and the first file to set a name wins, so
113
+ `load(".env.op", ".env")` lets a local `.env` fill in what the references
114
+ file lacks without overriding it. Missing files are skipped. Variables
115
+ already in the environment are kept unless `override=True`.
116
+ """
117
+ environ = os.environ if environ is None else environ
118
+ entries: list[Entry] = []
119
+ seen: set[str] = set()
120
+ for path in paths or (".env",):
121
+ file = Path(path).expanduser()
122
+ if not file.exists():
123
+ continue
124
+ for entry in parse_file(file):
125
+ if entry.name in seen:
126
+ continue
127
+ seen.add(entry.name)
128
+ entries.append(entry)
129
+
130
+ values = resolve(entries, registry=registry)
131
+ if export:
132
+ for name, value in values.items():
133
+ if override or name not in environ:
134
+ environ[name] = value
135
+ return values
envref/parse.py ADDED
@@ -0,0 +1,76 @@
1
+ """Read a `.env` file into entries, telling references from literals.
2
+
3
+ The grammar is the ordinary dotenv one — `KEY=value`, optional `export `,
4
+ `#` comments, single or double quotes — plus one rule: a value that is
5
+ `{{ scheme://… }}` is a *reference*, to be resolved before export. That is
6
+ `op inject`'s mustache syntax, so files written for it are valid as they are.
7
+
8
+ A bare `scheme://…` without braces is also flagged as a possible reference,
9
+ because the 1Password SDK and `op read` speak that form. Whether it is treated
10
+ as one is decided later, by whether a resolver for that scheme exists: an
11
+ `https://` URL stays a literal, an `op://` without braces resolves.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import re
17
+ from dataclasses import dataclass
18
+ from pathlib import Path
19
+
20
+ _ASSIGNMENT = re.compile(r"^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$")
21
+ _MUSTACHE = re.compile(r"^\{\{\s*([A-Za-z][A-Za-z0-9+.-]*://[^}]*?)\s*\}\}$")
22
+ _BARE = re.compile(r"^([A-Za-z][A-Za-z0-9+.-]*)://\S+$")
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class Entry:
27
+ name: str
28
+ value: str
29
+ """The literal value, or the reference text (`scheme://…`) when `ref` is set."""
30
+ ref: str | None
31
+ """`scheme://…` for a reference, None for a literal."""
32
+ explicit: bool
33
+ """True when the reference was written with `{{ }}`: an unknown scheme is then an error."""
34
+ line: int
35
+
36
+ @property
37
+ def scheme(self) -> str | None:
38
+ return self.ref.partition("://")[0] if self.ref else None
39
+
40
+
41
+ def _unquote(value: str) -> str:
42
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
43
+ inner = value[1:-1]
44
+ if value[0] == '"':
45
+ inner = inner.replace(r"\n", "\n").replace(r"\"", '"').replace(r"\\", "\\")
46
+ return inner
47
+ # An unquoted value ends at ` #`, the way dotenv parsers agree it does.
48
+ head, _sep, _comment = value.partition(" #")
49
+ return head.rstrip()
50
+
51
+
52
+ def parse(text: str) -> list[Entry]:
53
+ """Every assignment in `text`, in file order. Later duplicates win at load time."""
54
+ entries: list[Entry] = []
55
+ for number, raw in enumerate(text.splitlines(), start=1):
56
+ if not raw.strip() or raw.lstrip().startswith("#"):
57
+ continue
58
+ match = _ASSIGNMENT.match(raw)
59
+ if match is None:
60
+ continue
61
+ name, value = match.group(1), match.group(2)
62
+ mustache = _MUSTACHE.match(value)
63
+ if mustache:
64
+ entries.append(Entry(name, mustache.group(1), mustache.group(1), True, number))
65
+ continue
66
+ value = _unquote(value)
67
+ bare = _BARE.match(value)
68
+ if bare:
69
+ entries.append(Entry(name, value, value, False, number))
70
+ else:
71
+ entries.append(Entry(name, value, None, False, number))
72
+ return entries
73
+
74
+
75
+ def parse_file(path: str | Path) -> list[Entry]:
76
+ return parse(Path(path).expanduser().read_text())
@@ -0,0 +1,33 @@
1
+ """Find resolvers: the built-in four plus anything installed under the
2
+ `envref.resolvers` entry-point group (name = scheme, value = `module:Class`)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from functools import lru_cache
7
+ from importlib.metadata import entry_points
8
+
9
+ from envref.core import Resolver
10
+
11
+ _GROUP = "envref.resolvers"
12
+
13
+
14
+ def _builtins() -> dict[str, Resolver]:
15
+ from envref.resolvers.env import EnvResolver
16
+ from envref.resolvers.file import FileResolver
17
+ from envref.resolvers.keychain import KeychainResolver
18
+ from envref.resolvers.op import OnePasswordResolver
19
+
20
+ found = [EnvResolver(), FileResolver(), KeychainResolver(), OnePasswordResolver()]
21
+ return {r.scheme: r for r in found}
22
+
23
+
24
+ @lru_cache(maxsize=1)
25
+ def registry() -> dict[str, Resolver]:
26
+ """Scheme → resolver instance. Entry points override built-ins of the same name,
27
+ so a user can swap the 1Password implementation without forking."""
28
+ found = _builtins()
29
+ for ep in entry_points(group=_GROUP):
30
+ if ep.value.startswith("envref.resolvers."):
31
+ continue # our own registrations, already instantiated above
32
+ found[ep.name] = ep.load()()
33
+ return found
@@ -0,0 +1,29 @@
1
+ """`env://NAME` — another environment variable. Useful for aliasing a name a
2
+ library insists on (`GITHUB_TOKEN={{ env://GH_TOKEN }}`)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import os
7
+ from collections.abc import Sequence
8
+
9
+ from envref.core import ResolveError
10
+
11
+
12
+ class EnvResolver:
13
+ scheme = "env"
14
+
15
+ def resolve(self, refs: Sequence[str]) -> dict[str, str]:
16
+ values: dict[str, str] = {}
17
+ failures: dict[str, str] = {}
18
+ for ref in refs:
19
+ name = ref.partition("://")[2]
20
+ if name in os.environ:
21
+ values[ref] = os.environ[name]
22
+ else:
23
+ failures[ref] = f"{name} is not set"
24
+ if failures:
25
+ raise ResolveError(failures)
26
+ return values
27
+
28
+ def doctor(self) -> str:
29
+ return "ok"
@@ -0,0 +1,33 @@
1
+ """`file://path` — a file's contents, for keys and certificates that libraries
2
+ want as a string (`TLS_KEY={{ file://~/.ssh/server.pem }}`). `~` expands; a
3
+ relative path is relative to the working directory. One trailing newline is
4
+ dropped, since nothing wants it in an environment variable."""
5
+
6
+ from __future__ import annotations
7
+
8
+ from collections.abc import Sequence
9
+ from pathlib import Path
10
+
11
+ from envref.core import ResolveError
12
+
13
+
14
+ class FileResolver:
15
+ scheme = "file"
16
+
17
+ def resolve(self, refs: Sequence[str]) -> dict[str, str]:
18
+ values: dict[str, str] = {}
19
+ failures: dict[str, str] = {}
20
+ for ref in refs:
21
+ path = Path(ref.partition("://")[2]).expanduser()
22
+ try:
23
+ text = path.read_text()
24
+ except OSError as exc:
25
+ failures[ref] = exc.strerror or str(exc)
26
+ continue
27
+ values[ref] = text[:-1] if text.endswith("\n") else text
28
+ if failures:
29
+ raise ResolveError(failures)
30
+ return values
31
+
32
+ def doctor(self) -> str:
33
+ return "ok"
@@ -0,0 +1,80 @@
1
+ """`keychain://service/account` — a macOS Keychain generic password.
2
+
3
+ Reads with `security find-generic-password -w`. macOS shows its own
4
+ authorisation dialog when the item's access control asks for one. Set
5
+ `ENVREF_KEYCHAIN_TOUCH_ID=1` to require Touch ID in front of every read via the
6
+ bundled helper (see `envref.touchid`). Store an item with:
7
+
8
+ security add-generic-password -U -s <service> -a <account> -w
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import platform
15
+ import shutil
16
+ import subprocess
17
+ from collections.abc import Sequence
18
+
19
+ from envref.core import ResolveError, ResolverUnavailable
20
+
21
+
22
+ def read_generic_password(service: str, account: str | None) -> str | None:
23
+ """The password, or None when the item does not exist or the user declined."""
24
+ cmd = ["security", "find-generic-password", "-w", "-s", service]
25
+ if account:
26
+ cmd += ["-a", account]
27
+ result = subprocess.run(cmd, capture_output=True, text=True, check=False)
28
+ if result.returncode != 0:
29
+ return None
30
+ return result.stdout.rstrip("\n") or None
31
+
32
+
33
+ def save_generic_password(service: str, account: str, password: str) -> None:
34
+ """Create or update (-U) the item. The creating tool, `security`, is placed on
35
+ the item's access list, so later reads from it are silent."""
36
+ subprocess.run(
37
+ ["security", "add-generic-password", "-U", "-s", service, "-a", account, "-w", password],
38
+ check=True,
39
+ capture_output=True,
40
+ text=True,
41
+ )
42
+
43
+
44
+ class KeychainResolver:
45
+ scheme = "keychain"
46
+
47
+ def available(self) -> str | None:
48
+ """Why this resolver cannot run here, or None when it can."""
49
+ if platform.system() != "Darwin":
50
+ return "macOS Keychain is only available on macOS"
51
+ if not shutil.which("security"):
52
+ return "the `security` tool is not on PATH"
53
+ return None
54
+
55
+ def resolve(self, refs: Sequence[str]) -> dict[str, str]:
56
+ reason = self.available()
57
+ if reason:
58
+ raise ResolverUnavailable(reason)
59
+
60
+ touch_id = os.environ.get("ENVREF_KEYCHAIN_TOUCH_ID", "") not in ("", "0", "false")
61
+ values: dict[str, str] = {}
62
+ failures: dict[str, str] = {}
63
+ for ref in refs:
64
+ service, _, account = ref.partition("://")[2].partition("/")
65
+ if touch_id:
66
+ from envref.touchid import read_password_with_touch_id
67
+
68
+ value = read_password_with_touch_id(service, account or None, "unlock the keychain")
69
+ else:
70
+ value = read_generic_password(service, account or None)
71
+ if value is None:
72
+ failures[ref] = "no such item, or access was denied"
73
+ else:
74
+ values[ref] = value
75
+ if failures:
76
+ raise ResolveError(failures)
77
+ return values
78
+
79
+ def doctor(self) -> str:
80
+ return self.available() or "ok"
envref/resolvers/op.py ADDED
@@ -0,0 +1,347 @@
1
+ """`op://vault/item/field` — 1Password.
2
+
3
+ Two ways to ask, picked by the environment:
4
+
5
+ * **Service account** when `OP_SERVICE_ACCOUNT_TOKEN` is set: the `onepassword-sdk`
6
+ package (`pip install envref[op]`), no binary, no biometrics, nobody present.
7
+ For containers and scheduled jobs.
8
+ * **CLI** otherwise: the `op` binary on PATH. All references go through one
9
+ `op inject` call. If that fails for want of a session, `op signin` mints one,
10
+ the account password coming from macOS Keychain behind Touch ID when
11
+ `envref op save-password` has stored it there, or from the terminal when not.
12
+ The session token is cached (see SECURITY.md) so a burst of commands costs one
13
+ sign-in rather than one each; concurrent processes share it through a lock.
14
+
15
+ Session caching presumes "biometric unlock for the 1Password CLI" is *off* in
16
+ the 1Password app. With it on, `op signin --raw` returns nothing and `op`
17
+ prompts per invocation; this resolver then degrades to a single `op inject`.
18
+
19
+ Environment knobs: `OP_ACCOUNT` (shorthand, when several accounts are signed
20
+ in), `ENVREF_OP_SESSION_TTL` (seconds, default 1500, 0 disables the cache),
21
+ `ENVREF_OP_KEYCHAIN_SERVICE` (default `envref-op`), `ENVREF_OP_KEYCHAIN_ACCOUNT`
22
+ (default: the account's email).
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import asyncio
28
+ import fcntl
29
+ import json
30
+ import os
31
+ import re
32
+ import secrets
33
+ import shutil
34
+ import subprocess
35
+ import time
36
+ from collections.abc import Iterator, Sequence
37
+ from concurrent.futures import ThreadPoolExecutor
38
+ from contextlib import contextmanager
39
+ from pathlib import Path
40
+
41
+ from envref.core import ResolveError, ResolverUnavailable
42
+ from envref.touchid import cache_dir
43
+
44
+ KEYCHAIN_SERVICE_DEFAULT = "envref-op"
45
+ _INTEGRATION_NAME = "envref"
46
+ _INTEGRATION_VERSION = "v1.0.0"
47
+
48
+
49
+ def session_ttl() -> int:
50
+ """Buffer under op's ~30-minute sliding timeout, so renewal happens before a
51
+ mid-run auth failure. 0 disables caching altogether."""
52
+ return int(os.environ.get("ENVREF_OP_SESSION_TTL", 25 * 60))
53
+
54
+
55
+ def session_cache_path() -> Path:
56
+ return cache_dir() / "op-session.json"
57
+
58
+
59
+ def keychain_service() -> str:
60
+ return os.environ.get("ENVREF_OP_KEYCHAIN_SERVICE", KEYCHAIN_SERVICE_DEFAULT)
61
+
62
+
63
+ # --- session cache --------------------------------------------------------
64
+
65
+
66
+ def read_cached_session() -> dict | None:
67
+ path = session_cache_path()
68
+ if session_ttl() <= 0 or not path.exists():
69
+ return None
70
+ try:
71
+ data = json.loads(path.read_text())
72
+ except (OSError, json.JSONDecodeError):
73
+ return None
74
+ if not (data.get("account") and data.get("token")):
75
+ return None
76
+ if time.time() - float(data.get("mtime", 0)) > session_ttl():
77
+ return None
78
+ return data
79
+
80
+
81
+ def write_cached_session(account: str, token: str) -> None:
82
+ if session_ttl() <= 0:
83
+ return
84
+ path = session_cache_path()
85
+ path.parent.mkdir(parents=True, exist_ok=True)
86
+ path.parent.chmod(0o700)
87
+ tmp = path.with_suffix(".json.tmp")
88
+ tmp.write_text(json.dumps({"account": account, "token": token, "mtime": time.time()}))
89
+ tmp.chmod(0o600)
90
+ tmp.replace(path)
91
+
92
+
93
+ def touch_cached_session() -> None:
94
+ """A successful use extends op's sliding window; mirror that in the cache."""
95
+ path = session_cache_path()
96
+ if not path.exists():
97
+ return
98
+ try:
99
+ data = json.loads(path.read_text())
100
+ data["mtime"] = time.time()
101
+ path.write_text(json.dumps(data))
102
+ path.chmod(0o600)
103
+ except (OSError, json.JSONDecodeError):
104
+ return
105
+
106
+
107
+ @contextmanager
108
+ def _signin_lock() -> Iterator[None]:
109
+ """Serialise sign-ins across processes, so shells that start together on an
110
+ expired session raise one Touch ID prompt rather than one each."""
111
+ lock = cache_dir() / "op-signin.lock"
112
+ lock.parent.mkdir(parents=True, exist_ok=True)
113
+ with lock.open("w") as handle:
114
+ fcntl.flock(handle, fcntl.LOCK_EX)
115
+ try:
116
+ yield
117
+ finally:
118
+ fcntl.flock(handle, fcntl.LOCK_UN)
119
+
120
+
121
+ # --- accounts -------------------------------------------------------------
122
+
123
+
124
+ def _accounts() -> list[dict]:
125
+ result = subprocess.run(
126
+ ["op", "account", "list", "--format=json"], capture_output=True, text=True, check=False
127
+ )
128
+ if result.returncode != 0:
129
+ return []
130
+ try:
131
+ return json.loads(result.stdout or "[]")
132
+ except json.JSONDecodeError:
133
+ return []
134
+
135
+
136
+ def resolve_account(cached: dict | None = None) -> str | None:
137
+ """The account shorthand to sign in to: `OP_ACCOUNT`, else the cached one,
138
+ else the only signed-in account. Several accounts with no `OP_ACCOUNT` is an
139
+ error, since guessing would sign in to the wrong vault."""
140
+ if os.environ.get("OP_ACCOUNT"):
141
+ return os.environ["OP_ACCOUNT"]
142
+ if cached and cached.get("account"):
143
+ return cached["account"]
144
+ accounts = _accounts()
145
+ if len(accounts) == 1:
146
+ return accounts[0].get("shorthand") or accounts[0].get("account_uuid")
147
+ if len(accounts) > 1:
148
+ raise ResolveError(
149
+ {"op://": "several 1Password accounts are signed in"},
150
+ "Set OP_ACCOUNT to the shorthand of the one to use.",
151
+ )
152
+ return None
153
+
154
+
155
+ def account_email(account: str) -> str | None:
156
+ for acct in _accounts():
157
+ if account in (
158
+ acct.get("shorthand"),
159
+ acct.get("email"),
160
+ acct.get("account_uuid"),
161
+ acct.get("user_uuid"),
162
+ ):
163
+ return acct.get("email")
164
+ return None
165
+
166
+
167
+ def keychain_account(account: str) -> str:
168
+ return os.environ.get("ENVREF_OP_KEYCHAIN_ACCOUNT") or account_email(account) or account
169
+
170
+
171
+ def signin(account: str) -> str | None:
172
+ """Mint a session token. The password comes from Keychain (behind Touch ID)
173
+ when stored; otherwise `op signin` prompts on the terminal."""
174
+ from envref.touchid import read_password_with_touch_id
175
+
176
+ password = read_password_with_touch_id(
177
+ keychain_service(), keychain_account(account), "unlock the 1Password session"
178
+ )
179
+ cmd = ["op", "signin", "--raw", "--account", account]
180
+ if password is not None:
181
+ result = subprocess.run(
182
+ cmd, input=password + "\n", capture_output=True, text=True, check=False
183
+ )
184
+ else:
185
+ result = subprocess.run(cmd, stdout=subprocess.PIPE, text=True, check=False)
186
+ if result.returncode != 0:
187
+ return None
188
+ return result.stdout.strip() or None
189
+
190
+
191
+ def save_password(account: str, password: str) -> tuple[str, str]:
192
+ """Store the account password in Keychain for `signin`. Returns (service, account)."""
193
+ from envref.resolvers.keychain import save_generic_password
194
+
195
+ service, name = keychain_service(), keychain_account(account)
196
+ save_generic_password(service, name, password)
197
+ return service, name
198
+
199
+
200
+ # --- resolution -----------------------------------------------------------
201
+
202
+
203
+ def _template(refs: Sequence[str], marker: str) -> str:
204
+ """One `op inject` template for every reference. Values are fenced by a
205
+ per-run random marker, so a multi-line value (a PEM key) survives intact."""
206
+ return "".join(f"{marker}{i}{marker}{{{{ {ref} }}}}{marker}\n" for i, ref in enumerate(refs))
207
+
208
+
209
+ def _parse_injected(output: str, refs: Sequence[str], marker: str) -> dict[str, str]:
210
+ pattern = re.compile(re.escape(marker) + r"(\d+)" + re.escape(marker) + r"(.*?)" + re.escape(marker), re.S)
211
+ return {refs[int(index)]: value for index, value in pattern.findall(output)}
212
+
213
+
214
+ def _inject(refs: Sequence[str], env: dict[str, str]) -> tuple[dict[str, str] | None, str]:
215
+ """Resolve via `op inject`. (values, "") on success, (None, stderr) on failure."""
216
+ # Printable on purpose: `op inject` drops control characters from its output.
217
+ marker = f"@envref{secrets.token_hex(8)}@"
218
+ result = subprocess.run(
219
+ ["op", "inject"],
220
+ input=_template(refs, marker),
221
+ capture_output=True,
222
+ text=True,
223
+ env=env,
224
+ check=False,
225
+ )
226
+ if result.returncode != 0:
227
+ return None, result.stderr.strip()
228
+ return _parse_injected(result.stdout, refs, marker), ""
229
+
230
+
231
+ def _looks_like_auth_failure(stderr: str) -> bool:
232
+ """Only an authentication failure is worth a sign-in and retry; a reference
233
+ that does not exist would fail again identically."""
234
+ text = stderr.lower()
235
+ if not text:
236
+ return True
237
+ return any(
238
+ s in text
239
+ for s in ("signed in", "sign in", "signin", "session", "unauthori", "authenticat", "401")
240
+ )
241
+
242
+
243
+ class OnePasswordResolver:
244
+ scheme = "op"
245
+
246
+ # -- service account --
247
+ def _resolve_sdk(self, refs: Sequence[str], token: str) -> dict[str, str]:
248
+ try:
249
+ from onepassword.client import Client
250
+ except ImportError as exc:
251
+ raise ResolverUnavailable(
252
+ "OP_SERVICE_ACCOUNT_TOKEN is set but the 1Password SDK is not installed: "
253
+ "pip install 'envref[op]'"
254
+ ) from exc
255
+
256
+ async def go() -> dict[str, str]:
257
+ client = await Client.authenticate(
258
+ auth=token,
259
+ integration_name=_INTEGRATION_NAME,
260
+ integration_version=_INTEGRATION_VERSION,
261
+ )
262
+ response = await client.secrets.resolve_all(list(refs))
263
+ values: dict[str, str] = {}
264
+ failures: dict[str, str] = {}
265
+ for ref, individual in response.individual_responses.items():
266
+ if individual.content is not None:
267
+ values[ref] = individual.content.secret
268
+ else:
269
+ failures[ref] = str(individual.error)
270
+ if failures:
271
+ raise ResolveError(
272
+ failures, "Check the service account has read access to that vault."
273
+ )
274
+ return values
275
+
276
+ # Synchronous by contract (callers load config at import time), but the
277
+ # SDK is async. Inside a running loop, run it on a worker thread.
278
+ try:
279
+ asyncio.get_running_loop()
280
+ except RuntimeError:
281
+ return asyncio.run(go())
282
+ with ThreadPoolExecutor(max_workers=1) as pool:
283
+ return pool.submit(lambda: asyncio.run(go())).result()
284
+
285
+ # -- CLI --
286
+ def _resolve_cli(self, refs: Sequence[str]) -> dict[str, str]:
287
+ if not shutil.which("op"):
288
+ raise ResolverUnavailable(
289
+ "the `op` CLI is not on PATH and OP_SERVICE_ACCOUNT_TOKEN is not set"
290
+ )
291
+ cached = read_cached_session()
292
+ env = dict(os.environ)
293
+ if cached:
294
+ env[f"OP_SESSION_{cached['account']}"] = cached["token"]
295
+
296
+ values, stderr = _inject(refs, env)
297
+ if values is not None:
298
+ if cached:
299
+ touch_cached_session()
300
+ return values
301
+ if not _looks_like_auth_failure(stderr):
302
+ raise ResolveError({ref: stderr or "op inject failed" for ref in refs})
303
+
304
+ with _signin_lock():
305
+ # Another process may have signed in while this one waited.
306
+ fresh = read_cached_session()
307
+ if fresh and fresh != cached:
308
+ env[f"OP_SESSION_{fresh['account']}"] = fresh["token"]
309
+ values, stderr = _inject(refs, env)
310
+ if values is not None:
311
+ return values
312
+ account = resolve_account(fresh or cached)
313
+ token = signin(account) if account else None
314
+ if not (account and token):
315
+ raise ResolveError(
316
+ {ref: stderr or "not signed in" for ref in refs},
317
+ "Run `op signin`, or `envref op save-password` to let envref sign in.",
318
+ )
319
+ env[f"OP_SESSION_{account}"] = token
320
+ write_cached_session(account, token)
321
+ values, stderr = _inject(refs, env)
322
+ if values is None:
323
+ raise ResolveError({ref: stderr or "op inject failed" for ref in refs})
324
+ return values
325
+
326
+ def resolve(self, refs: Sequence[str]) -> dict[str, str]:
327
+ token = os.environ.get("OP_SERVICE_ACCOUNT_TOKEN", "")
328
+ if token:
329
+ return self._resolve_sdk(refs, token)
330
+ return self._resolve_cli(refs)
331
+
332
+ def doctor(self) -> str:
333
+ if os.environ.get("OP_SERVICE_ACCOUNT_TOKEN"):
334
+ try:
335
+ import onepassword # noqa: F401
336
+ except ImportError:
337
+ return "service account token set, but SDK missing: pip install 'envref[op]'"
338
+ return "service account"
339
+ if not shutil.which("op"):
340
+ return "`op` CLI not on PATH"
341
+ cached = read_cached_session()
342
+ if cached:
343
+ age = int(time.time() - float(cached.get("mtime", 0)))
344
+ return f"op CLI, session cached for {cached['account']} ({age}s old)"
345
+ if session_ttl() <= 0:
346
+ return "op CLI, session cache disabled"
347
+ return "op CLI, no cached session (next load signs in)"
@@ -0,0 +1,76 @@
1
+ import Foundation
2
+ import LocalAuthentication
3
+
4
+ // Touch-ID-gated read of a generic-password Keychain item.
5
+ //
6
+ // Items with a biometric access control (kSecAccessControlBiometryCurrentSet)
7
+ // need an Apple-Developer-signed binary with Keychain entitlements, which a
8
+ // helper compiled on the fly does not have. So the responsibility is split:
9
+ // 1. LAContext.evaluatePolicy fires Touch ID (no entitlements needed).
10
+ // 2. Only once that succeeds, /usr/bin/security reads the password.
11
+ //
12
+ // For step 2 to be silent, the item must trust /usr/bin/security. That is the
13
+ // case when `security add-generic-password` created it (envref's
14
+ // `op save-password` does), or when "Always Allow" was clicked once.
15
+ //
16
+ // Usage: envref-touchid get <service> <account> [reason]
17
+ // exit 0 → password on stdout
18
+ // exit 2 → user cancelled / Touch ID failed
19
+ // exit 3 → item not found
20
+ // exit 1 → other error (message on stderr)
21
+
22
+ func die(_ msg: String, _ code: Int32 = 1) -> Never {
23
+ FileHandle.standardError.write(Data((msg + "\n").utf8))
24
+ exit(code)
25
+ }
26
+
27
+ let args = CommandLine.arguments
28
+ guard args.count >= 4, args[1] == "get" else {
29
+ die("usage: \(args[0]) get <service> <account> [reason]")
30
+ }
31
+ let service = args[2]
32
+ let account = args[3]
33
+ let reason = args.count >= 5 && !args[4].isEmpty ? args[4] : "read a secret from the keychain"
34
+
35
+ let context = LAContext()
36
+ var laError: NSError?
37
+ guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &laError) else {
38
+ die("biometrics unavailable: \(laError?.localizedDescription ?? "unknown")")
39
+ }
40
+
41
+ let sem = DispatchSemaphore(value: 0)
42
+ var authOK = false
43
+ context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: reason) { success, _ in
44
+ authOK = success
45
+ sem.signal()
46
+ }
47
+ sem.wait()
48
+ guard authOK else { exit(2) }
49
+
50
+ let task = Process()
51
+ task.launchPath = "/usr/bin/security"
52
+ var arguments = ["find-generic-password", "-w", "-s", service]
53
+ if !account.isEmpty {
54
+ arguments += ["-a", account]
55
+ }
56
+ task.arguments = arguments
57
+ let outPipe = Pipe()
58
+ let errPipe = Pipe()
59
+ task.standardOutput = outPipe
60
+ task.standardError = errPipe
61
+ do {
62
+ try task.run()
63
+ } catch {
64
+ die("failed to launch /usr/bin/security: \(error)")
65
+ }
66
+ task.waitUntilExit()
67
+
68
+ if task.terminationStatus == 44 { // errSecItemNotFound
69
+ exit(3)
70
+ }
71
+ if task.terminationStatus != 0 {
72
+ let errText = String(data: errPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
73
+ die("security returned \(task.terminationStatus): \(errText)")
74
+ }
75
+
76
+ FileHandle.standardOutput.write(outPipe.fileHandleForReading.readDataToEndOfFile())
envref/touchid.py ADDED
@@ -0,0 +1,66 @@
1
+ """A Touch ID gate in front of a Keychain read.
2
+
3
+ Keychain items with a biometric access control (`kSecAccessControlBiometryCurrentSet`)
4
+ need a Developer-signed binary with entitlements, which a script compiled on
5
+ the fly does not have. So the helper splits the job: `LAContext` asks for the
6
+ fingerprint (no entitlements needed), and only on success does it call
7
+ `/usr/bin/security` to read an ordinary generic-password item. The item must
8
+ let `security` read it silently, which is the case when `security` created it.
9
+
10
+ The helper is compiled from `touchid/helper.swift` on first use into the cache
11
+ directory. Its file name is what macOS shows in the dialog ("envref wants to
12
+ authenticate"). Without `swiftc` or biometrics the caller falls back to a plain
13
+ `security` read, which shows the system's own authorisation dialog instead.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ import shutil
20
+ import subprocess
21
+ from pathlib import Path
22
+
23
+ HELPER_SOURCE = Path(__file__).with_name("touchid") / "helper.swift"
24
+
25
+
26
+ def cache_dir() -> Path:
27
+ base = os.environ.get("XDG_CACHE_HOME") or str(Path.home() / ".cache")
28
+ return Path(base).expanduser() / "envref"
29
+
30
+
31
+ def helper_binary() -> Path | None:
32
+ """Path to the compiled helper, building it if missing or stale. None when
33
+ the source is missing or `swiftc` is not installed."""
34
+ if not HELPER_SOURCE.exists() or not shutil.which("swiftc"):
35
+ return None
36
+ binary = cache_dir() / "touchid" / "envref"
37
+ if binary.exists() and binary.stat().st_mtime >= HELPER_SOURCE.stat().st_mtime:
38
+ return binary
39
+ binary.parent.mkdir(parents=True, exist_ok=True)
40
+ result = subprocess.run(
41
+ ["swiftc", str(HELPER_SOURCE), "-o", str(binary)],
42
+ capture_output=True,
43
+ text=True,
44
+ check=False,
45
+ )
46
+ if result.returncode != 0:
47
+ return None
48
+ return binary
49
+
50
+
51
+ def read_password_with_touch_id(service: str, account: str | None, reason: str) -> str | None:
52
+ """Touch ID, then the Keychain item. Falls back to `security` alone when the
53
+ helper cannot be built. None when the item is missing or the user declined."""
54
+ binary = helper_binary()
55
+ if binary is None:
56
+ from envref.resolvers.keychain import read_generic_password
57
+
58
+ return read_generic_password(service, account)
59
+ result = subprocess.run(
60
+ [str(binary), "get", service, account or "", reason],
61
+ capture_output=True,
62
+ check=False,
63
+ )
64
+ if result.returncode != 0:
65
+ return None
66
+ return result.stdout.decode("utf-8", "replace").rstrip("\n") or None
@@ -0,0 +1,188 @@
1
+ Metadata-Version: 2.5
2
+ Name: envref
3
+ Version: 0.1.0
4
+ Summary: A .env where any value can be a reference into a secret store, resolved at load time. op://, keychain://, file://, env://, and your own.
5
+ Project-URL: Homepage, https://github.com/sebaheg/envref
6
+ Project-URL: Repository, https://github.com/sebaheg/envref
7
+ Project-URL: Issues, https://github.com/sebaheg/envref/issues
8
+ Project-URL: Changelog, https://github.com/sebaheg/envref/blob/main/CHANGELOG.md
9
+ Author: Sebastian Haglund
10
+ License: MIT
11
+ License-File: LICENSE
12
+ Keywords: 1password,dotenv,environment,keychain,secrets,twelve-factor
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Environment :: Console
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Operating System :: MacOS
18
+ Classifier: Operating System :: POSIX :: Linux
19
+ Classifier: Programming Language :: Python :: 3
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Topic :: Security
24
+ Classifier: Topic :: Software Development :: Libraries
25
+ Classifier: Topic :: Utilities
26
+ Requires-Python: >=3.11
27
+ Provides-Extra: op
28
+ Requires-Dist: onepassword-sdk>=0.3; (python_version < '3.14') and extra == 'op'
29
+ Description-Content-Type: text/markdown
30
+
31
+ # envref
32
+
33
+ A `.env` where any value can be a reference into a secret store, resolved
34
+ when the file is loaded.
35
+
36
+ ```dotenv
37
+ # .env
38
+ ATTIO_KEY={{ op://work/Attio/api_key }} # 1Password
39
+ DB_PASSWORD={{ keychain://myapp/postgres }} # macOS Keychain
40
+ TLS_KEY={{ file://~/.ssh/server.pem }} # a file's contents
41
+ GITHUB_TOKEN={{ env://GH_TOKEN }} # another variable
42
+ REGION=eu-north-1 # a plain literal
43
+ ```
44
+
45
+ ```bash
46
+ envref run -- python app.py # app.py sees ATTIO_KEY=<the real key>
47
+ ```
48
+
49
+ ```python
50
+ import envref
51
+ envref.load(".env") # same thing, into os.environ
52
+ ```
53
+
54
+ The file holds no secrets, so it can be committed. The scheme of each
55
+ reference picks the backend. Four ship built in, and your own takes one class
56
+ and one entry point.
57
+
58
+ ## Why
59
+
60
+ `op run`, `berglas exec`, `chamber exec` and friends each do this for one
61
+ store. Teams rarely have one store. A laptop has 1Password and Keychain, a
62
+ container has a service account, a CI job has its own vault, and the same
63
+ `.env` should work in all three with the same names. `envref` keeps the file
64
+ format (plain dotenv, plus `{{ scheme://… }}`), and makes the backend a plugin.
65
+
66
+ ## Install
67
+
68
+ ```bash
69
+ uv tool install envref # CLI
70
+ pip install envref # library, zero dependencies
71
+ pip install 'envref[op]' # + 1Password service-account support (SDK)
72
+ ```
73
+
74
+ The `op://` CLI path needs only the [`op`](https://developer.1password.com/docs/cli/)
75
+ binary on PATH, no extra.
76
+
77
+ ## Resolvers
78
+
79
+ | Scheme | Resolves to | Needs |
80
+ | --- | --- | --- |
81
+ | `op://vault/item/field` | a 1Password field | `op` CLI, or `OP_SERVICE_ACCOUNT_TOKEN` + `envref[op]` |
82
+ | `keychain://service/account` | a macOS Keychain generic password | macOS |
83
+ | `file://path` | the file's contents, one trailing newline dropped | — |
84
+ | `env://NAME` | another environment variable | — |
85
+
86
+ A value written as `{{ scheme://… }}` is always a reference, and an unknown
87
+ scheme is an error. A bare `scheme://…` without braces is resolved only when a
88
+ resolver for that scheme is installed, so `URL=https://example.com` stays a
89
+ literal and `KEY=op://vault/item/field` still works for files written for
90
+ `op read`.
91
+
92
+ ### 1Password in detail
93
+
94
+ Two modes, chosen by the environment:
95
+
96
+ - **Service account** when `OP_SERVICE_ACCOUNT_TOKEN` is set. The SDK resolves
97
+ every reference in one round trip. No binary, no biometrics. For containers.
98
+ - **CLI** otherwise. Every reference goes through one `op inject`. If `op` has
99
+ no session, `envref` signs in: the account password comes from Keychain
100
+ behind Touch ID if you stored it with `envref op save-password`, or from the
101
+ terminal if not. The session token is cached in `~/.cache/envref/` so a
102
+ burst of commands costs one sign-in; concurrent processes share it through
103
+ a lock. See [SECURITY.md](SECURITY.md) for what that cache means.
104
+
105
+ Session caching presumes "biometric unlock for the CLI" is off in the
106
+ 1Password app. With it on, `op` prompts per invocation and `envref` holds no
107
+ token.
108
+
109
+ Knobs: `OP_ACCOUNT` (shorthand, when several accounts are signed in),
110
+ `ENVREF_OP_SESSION_TTL` (seconds, default 1500, `0` disables the cache),
111
+ `ENVREF_OP_KEYCHAIN_SERVICE` / `ENVREF_OP_KEYCHAIN_ACCOUNT` (where the
112
+ password is stored, default `envref-op` / the account's email).
113
+
114
+ ## CLI
115
+
116
+ ```bash
117
+ envref run [-f FILE]... [--override] -- cmd args # exec cmd with the resolved environment
118
+ envref export [-f FILE]... [--format shell|dotenv|json]
119
+ envref check [-f FILE]... # resolve and report per key; never prints a value
120
+ envref doctor # installed resolvers and whether each can run here
121
+ envref op save-password [--account SHORTHAND] # store the 1Password password in Keychain for Touch ID sign-in
122
+ ```
123
+
124
+ `-f` repeats. The first file to set a name wins, so `-f .env.refs -f .env`
125
+ lets a local `.env` fill in what the shared references file lacks without
126
+ overriding it. Default is `.env`.
127
+
128
+ ## Library
129
+
130
+ ```python
131
+ import envref
132
+
133
+ values = envref.load(".env.refs", ".env") # dict, also exported into os.environ
134
+ values = envref.load(".env", export=False) # dict only
135
+ envref.load(".env", override=True) # let the file win over existing variables
136
+
137
+ entries = envref.parse(text) # list[Entry], refs identified
138
+ envref.resolve(entries, registry={"op": ...}) # bring your own resolvers
139
+ ```
140
+
141
+ Errors are typed: `UnknownScheme`, `ResolverUnavailable` (the backend cannot
142
+ run here: no `op`, not macOS, no token) and `ResolveError` (which references
143
+ failed and why, in `.failures`). All derive from `EnvrefError`.
144
+
145
+ ## Writing a resolver
146
+
147
+ ```python
148
+ # myenvref/aws.py
149
+ class SecretsManagerResolver:
150
+ scheme = "awssm"
151
+
152
+ def resolve(self, refs: list[str]) -> dict[str, str]:
153
+ # refs are every awssm:// reference in the file, deduplicated: batch them.
154
+ ...
155
+ return {ref: value for ref, value in ...}
156
+
157
+ def doctor(self) -> str: # optional, shown by `envref doctor`
158
+ return "ok"
159
+ ```
160
+
161
+ ```toml
162
+ # pyproject.toml
163
+ [project.entry-points."envref.resolvers"]
164
+ awssm = "myenvref.aws:SecretsManagerResolver"
165
+ ```
166
+
167
+ Installed next to `envref`, `{{ awssm://… }}` now resolves. An entry point
168
+ with a built-in's name replaces it.
169
+
170
+ ## Prior art
171
+
172
+ [`teller`](https://github.com/tellerops/teller) and
173
+ [`novops`](https://github.com/PierreBeucher/novops) are multi-provider too,
174
+ with the mapping in a YAML config rather than inline in the `.env`.
175
+ [`berglas`](https://github.com/GoogleCloudPlatform/berglas) has inline
176
+ `sm://` references, for Google Cloud only. [`dotenvx`](https://dotenvx.com)
177
+ encrypts the values rather than referencing them. `op inject` is what the
178
+ `{{ }}` syntax comes from, and any `.env.op` written for it is a valid
179
+ `envref` file.
180
+
181
+ ## Status
182
+
183
+ Extracted in November 2026 from a private automation repository where the
184
+ 1Password path had run daily for a year, on a laptop and in scheduled
185
+ containers. The other resolvers are new. macOS and Linux; Keychain and Touch
186
+ ID are macOS only.
187
+
188
+ MIT.
@@ -0,0 +1,16 @@
1
+ envref/__init__.py,sha256=aU9gMYe-w38M6TpkNpKpDXfgJoDNNAKKngeGTS_Itdw,902
2
+ envref/cli.py,sha256=dJRsW90LdLXGmLqM78A43p7zb4fjfgJCCHhlCX8yVCI,5840
3
+ envref/core.py,sha256=jXElmR2d1NTHc-PAy1vCKWkZzkLVAy5F6ejnrDCvk7Y,4660
4
+ envref/parse.py,sha256=XWOrAlQ4NXZ1D0NxIuuk65VHQe_LaQgeWuTWzXhLMgg,2892
5
+ envref/touchid.py,sha256=2tSggug_818Rmr6sbulNALHkpC6cNSFbqUal30EkZTQ,2565
6
+ envref/resolvers/__init__.py,sha256=g-v8xRhuwjccWdqoJfuWWcG2YNXkueGHFRMgdUI-lfU,1182
7
+ envref/resolvers/env.py,sha256=Cyf9Q7yi3PVQlrqjgRY_EMq1TMoVk9NaLpzdFrMD7uk,794
8
+ envref/resolvers/file.py,sha256=iNcncTZw3mlTHe0xwNQUaDrc3F0fM8arVJUbQT0wh_I,1076
9
+ envref/resolvers/keychain.py,sha256=BE1Wjer0PvA4pH4_Ur1Zksu4OYzMnLD_NJzaitQurio,2888
10
+ envref/resolvers/op.py,sha256=eXmS8-d8UOz85RRQWETFkaPdhnAMQBDp8LlhOq49hG4,12892
11
+ envref/touchid/helper.swift,sha256=cxyENwSNBU5CCsfeYYmjxpJ6Nfrz7Wi5dq7HpeGVlhs,2606
12
+ envref-0.1.0.dist-info/METADATA,sha256=utLrMrmcu1AZ2j5M4pyVwJq1dUD86S0ts_E-O6hAC1g,7402
13
+ envref-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
14
+ envref-0.1.0.dist-info/entry_points.txt,sha256=7ZJ460W344J4aZva_VPPWVwTylrSmdbiGqsKJ49aWeA,243
15
+ envref-0.1.0.dist-info/licenses/LICENSE,sha256=o5WVEWoP8tX5UUasTJYNUjsu0mj7zz78nHT-iGmN1j0,1074
16
+ envref-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,8 @@
1
+ [console_scripts]
2
+ envref = envref.cli:main
3
+
4
+ [envref.resolvers]
5
+ env = envref.resolvers.env:EnvResolver
6
+ file = envref.resolvers.file:FileResolver
7
+ keychain = envref.resolvers.keychain:KeychainResolver
8
+ op = envref.resolvers.op:OnePasswordResolver
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sebastian Haglund
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.