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 +40 -0
- envref/cli.py +166 -0
- envref/core.py +135 -0
- envref/parse.py +76 -0
- envref/resolvers/__init__.py +33 -0
- envref/resolvers/env.py +29 -0
- envref/resolvers/file.py +33 -0
- envref/resolvers/keychain.py +80 -0
- envref/resolvers/op.py +347 -0
- envref/touchid/helper.swift +76 -0
- envref/touchid.py +66 -0
- envref-0.1.0.dist-info/METADATA +188 -0
- envref-0.1.0.dist-info/RECORD +16 -0
- envref-0.1.0.dist-info/WHEEL +4 -0
- envref-0.1.0.dist-info/entry_points.txt +8 -0
- envref-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|
envref/resolvers/env.py
ADDED
|
@@ -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"
|
envref/resolvers/file.py
ADDED
|
@@ -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,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.
|