fherma 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.
fherma/__init__.py ADDED
File without changes
fherma/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
fherma/api.py ADDED
@@ -0,0 +1,107 @@
1
+ """A thin client over the platform's API.
2
+
3
+ Scaffolding is not here: it is a function of a signature, and `fherma_lang`
4
+ computes it in this process. The platform is asked for the signature and for
5
+ what only it knows — versions, points, status.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from typing import Any
12
+
13
+ import httpx
14
+
15
+ from .config import Profile
16
+ from .errors import AuthError, NetworkError, UsageError
17
+
18
+ #: Bumped when the wire contract changes. The server answers with the range it
19
+ #: speaks, so an outdated client is told to update rather than shown a 400.
20
+ API_VERSION = 1
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class SpecRef:
25
+ """`kernel/spec@version`, with the version optional."""
26
+
27
+ kernel: str
28
+ spec: str
29
+ version: str | None = None
30
+
31
+ @classmethod
32
+ def parse(cls, text: str) -> SpecRef:
33
+ body, _, version = text.partition("@")
34
+ kernel, sep, spec = body.partition("/")
35
+ if not sep or not kernel or not spec:
36
+ raise UsageError(
37
+ f"{text!r} is not a specification reference",
38
+ hint="Write it as kernel/spec@version, for example polymult/rlwe@1.0.0",
39
+ )
40
+ return cls(kernel=kernel, spec=spec, version=version or None)
41
+
42
+ @property
43
+ def path(self) -> str:
44
+ tail = f"@{self.version}" if self.version else ""
45
+ return f"/specs/{self.kernel}/{self.spec}{tail}"
46
+
47
+ def __str__(self) -> str:
48
+ tail = f"@{self.version}" if self.version else ""
49
+ return f"{self.kernel}/{self.spec}{tail}"
50
+
51
+
52
+ class Api:
53
+ def __init__(self, profile: Profile) -> None:
54
+ self._profile = profile
55
+ headers = {"accept": "application/json", "x-fherma-api": str(API_VERSION)}
56
+ if profile.token:
57
+ headers["authorization"] = f"Bearer {profile.token}"
58
+ self._client = httpx.Client(
59
+ base_url=f"{profile.url.rstrip('/')}/api",
60
+ headers=headers,
61
+ timeout=30.0,
62
+ )
63
+
64
+ def close(self) -> None:
65
+ self._client.close()
66
+
67
+ # ── plumbing ────────────────────────────────────────────────────────────
68
+ def _request(self, method: str, path: str, **kwargs: Any) -> Any:
69
+ try:
70
+ response = self._client.request(method, path, **kwargs)
71
+ except httpx.RequestError as error:
72
+ raise NetworkError(
73
+ f"cannot reach {self._profile.url}",
74
+ hint=f"{error.__class__.__name__}: {error}",
75
+ ) from error
76
+
77
+ if response.status_code in (401, 403):
78
+ raise AuthError(
79
+ "not authorised",
80
+ hint="Run `fherma auth login`, or set FHERMA_TOKEN.",
81
+ )
82
+ if response.status_code == 404:
83
+ raise UsageError(f"not found: {path}")
84
+ if response.status_code == 426:
85
+ raise NetworkError(
86
+ "this version of the CLI is too old for the platform",
87
+ hint="Run `pip install --upgrade fherma`.",
88
+ )
89
+ if response.status_code >= 400:
90
+ raise NetworkError(
91
+ f"the platform answered {response.status_code}",
92
+ hint=_short(response.text),
93
+ )
94
+ return response.json() if response.content else None
95
+
96
+ # ── endpoints ───────────────────────────────────────────────────────────
97
+ def whoami(self) -> dict:
98
+ return self._request("GET", "/auth/me")
99
+
100
+ def spec(self, ref: SpecRef) -> dict:
101
+ """Specification data: signature, description, points, status."""
102
+ return self._request("GET", ref.path)
103
+
104
+
105
+ def _short(text: str, limit: int = 200) -> str:
106
+ text = " ".join(text.split())
107
+ return text if len(text) <= limit else text[: limit - 1] + "…"
fherma/cli.py ADDED
@@ -0,0 +1,89 @@
1
+ """Entry point.
2
+
3
+ Command groups are not listed here. They are discovered through the
4
+ `fherma.commands` entry point group, so adding a noun means adding a module and
5
+ one line to `pyproject.toml` — and a separate distribution can add one without
6
+ touching this project at all.
7
+
8
+ `doctor` and `completion` are the exception, attached below by hand. They are
9
+ not nouns the platform has; they are about the installation, and they have to
10
+ keep working on the day discovery itself is what broke.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import sys
16
+ from importlib.metadata import entry_points
17
+
18
+ import click
19
+
20
+ from . import config, output, toolchain
21
+ from .api import Api
22
+ from .commands.toolchain import completion, doctor
23
+ from .errors import Exit, FhermaError
24
+
25
+
26
+ class Context:
27
+ """Shared state. Commands reach it through `click.pass_obj`."""
28
+
29
+ def __init__(self, profile_name: str | None) -> None:
30
+ self.profile = config.load(profile_name)
31
+ self._api: Api | None = None
32
+
33
+ @property
34
+ def api(self) -> Api:
35
+ # Built lazily so that offline commands — `check`, `case` — never open a
36
+ # connection and never need a token.
37
+ if self._api is None:
38
+ self._api = Api(self.profile)
39
+ return self._api
40
+
41
+
42
+ def _print_version(ctx: click.Context, _param: click.Parameter, value: bool) -> None:
43
+ """`--version` reports the toolchain, not this package.
44
+
45
+ An install is two distributions that move independently, and a bug report
46
+ quoting only one of them is a bug report missing half its evidence.
47
+ """
48
+ if not value or ctx.resilient_parsing:
49
+ return
50
+ click.echo(toolchain.describe())
51
+ ctx.exit()
52
+
53
+
54
+ @click.group(context_settings={"help_option_names": ["-h", "--help"]})
55
+ @click.option("-V", "--version", is_flag=True, is_eager=True, expose_value=False,
56
+ callback=_print_version, help="Show the version of every installed part.")
57
+ @click.option("--profile", envvar="FHERMA_PROFILE",
58
+ help="Which configured platform to talk to.")
59
+ @click.option("--json", "as_json", is_flag=True, help="Emit machine-readable output.")
60
+ @click.pass_context
61
+ def cli(ctx: click.Context, profile: str | None, as_json: bool) -> None:
62
+ """FHERMA — the kernel catalogue, from the command line."""
63
+ output.set_json(as_json)
64
+ ctx.obj = Context(profile)
65
+
66
+
67
+ cli.add_command(doctor)
68
+ cli.add_command(completion)
69
+
70
+
71
+ def _install_groups() -> None:
72
+ for entry in entry_points(group="fherma.commands"):
73
+ cli.add_command(entry.load(), name=entry.name)
74
+
75
+
76
+ def main() -> None:
77
+ _install_groups()
78
+ try:
79
+ cli.main(standalone_mode=False)
80
+ except FhermaError as error:
81
+ output.fail(error.message, error.hint)
82
+ sys.exit(int(error.exit_code))
83
+ except click.ClickException as error:
84
+ error.show()
85
+ sys.exit(int(Exit.USAGE))
86
+ except click.Abort:
87
+ output.fail("aborted")
88
+ sys.exit(int(Exit.USAGE))
89
+ sys.exit(int(Exit.OK))
@@ -0,0 +1 @@
1
+ """Command groups, discovered through entry points."""
@@ -0,0 +1,57 @@
1
+ """Who we are to the platform."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+
7
+ from .. import config, output
8
+ from ..api import Api
9
+ from ..errors import AuthError
10
+
11
+
12
+ @click.group()
13
+ def group() -> None:
14
+ """Log in, log out, and see who you are."""
15
+
16
+
17
+ @group.command()
18
+ @click.option("--token", prompt="API token", hide_input=True,
19
+ help="Read from the terminal when not given, so it stays out of shell history.")
20
+ @click.pass_obj
21
+ def login(ctx, token: str) -> None:
22
+ """Store a token for this profile."""
23
+ # Verify before writing. A token that is saved and then turns out to be
24
+ # wrong is worse than one that was never saved: the failure surfaces later,
25
+ # somewhere else.
26
+ probe = Api(config.Profile(name=ctx.profile.name, url=ctx.profile.url, token=token))
27
+ try:
28
+ me = probe.whoami()
29
+ finally:
30
+ probe.close()
31
+
32
+ config.save_token(ctx.profile.name, ctx.profile.url, token)
33
+ output.ok(f"logged in as {me.get('handle', 'unknown')} on {ctx.profile.name}")
34
+ output.emit({"profile": ctx.profile.name, "handle": me.get("handle")})
35
+
36
+
37
+ @group.command()
38
+ @click.pass_obj
39
+ def logout(ctx) -> None:
40
+ """Forget the stored token for this profile."""
41
+ if config.forget_token(ctx.profile.name):
42
+ output.ok(f"token removed from profile {ctx.profile.name}")
43
+ else:
44
+ output.note(f"no token stored for profile {ctx.profile.name}")
45
+ output.emit({"profile": ctx.profile.name, "token": None})
46
+
47
+
48
+ @group.command()
49
+ @click.pass_obj
50
+ def whoami(ctx) -> None:
51
+ """Show the account this profile is using."""
52
+ if not ctx.profile.token:
53
+ raise AuthError("no token for this profile", hint="Run `fherma auth login`.")
54
+ me = ctx.api.whoami()
55
+ output.say(f"{me.get('handle')} — {me.get('display_name', '')}")
56
+ output.note(f"{ctx.profile.name} · {ctx.profile.url}")
57
+ output.emit(me)
@@ -0,0 +1,187 @@
1
+ """The generator, the oracle and the verifier of a specification.
2
+
3
+ These three say where test data comes from, what the right answer is, and how a
4
+ verdict is reached. Their shapes are the same for every kernel in the catalogue;
5
+ only the types differ, and those come from the specification's signature.
6
+
7
+ The scaffold is produced by `fherma-lang`, in this process. The platform is
8
+ asked for one thing — the signature — so `init` needs no server at all when the
9
+ signature is already on disk.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from pathlib import Path
15
+
16
+ import click
17
+ from fherma_lang import LanguageError, emit, parse, reference
18
+ from fherma_lang.emit import PARTS
19
+
20
+ from .. import output, project
21
+ from ..api import SpecRef
22
+ from ..errors import CheckFailed, UsageError
23
+
24
+
25
+ @click.group()
26
+ def group() -> None:
27
+ """Write and check the generator, oracle and verifier."""
28
+
29
+
30
+ @group.command(short_help="fetch the bundle to write them in")
31
+ @click.argument("spec", required=False, metavar="KERNEL/SPEC[@VERSION]")
32
+ @click.option("--signature", type=click.Path(exists=True, dir_okay=False, path_type=Path),
33
+ help="Scaffold from a signature on disk instead of asking the platform.")
34
+ @click.option("--generator", "wanted", flag_value="generator", multiple=True,
35
+ help="Scaffold the generator.")
36
+ @click.option("--oracle", "wanted", flag_value="oracle", multiple=True,
37
+ help="Scaffold the oracle.")
38
+ @click.option("--verifier", "wanted", flag_value="verifier", multiple=True,
39
+ help="Scaffold the verifier. All three when none is named.")
40
+ @click.option("--out", type=click.Path(file_okay=False, path_type=Path), metavar="DIR",
41
+ help="Where to write. Named after the specification when omitted.")
42
+ @click.option("--force", is_flag=True, help="Overwrite files that are already there.")
43
+ @click.option("--update", is_flag=True,
44
+ help="Rewrite the generated files only, leaving yours untouched.")
45
+ @click.pass_obj
46
+ def init(ctx, spec: str | None, signature: Path | None, wanted: tuple[str, ...],
47
+ out: Path | None, force: bool, update: bool) -> None:
48
+ """Fetch the bundle for a specification.
49
+
50
+ \b
51
+ fherma testing init polymult/rlwe@1.0.0
52
+ fherma testing init polymult/rlwe # the latest published version
53
+ fherma testing init polymult/rlwe --oracle --verifier
54
+ fherma testing init --signature ./sig.fhk # no platform involved
55
+ """
56
+ if bool(spec) == bool(signature):
57
+ raise UsageError(
58
+ "name a specification, or pass --signature",
59
+ hint="fherma testing init polymult/rlwe@1.0.0",
60
+ )
61
+
62
+ ref: SpecRef | None = None
63
+ if signature:
64
+ text = signature.read_text(encoding="utf-8")
65
+ else:
66
+ ref = SpecRef.parse(spec)
67
+ data = ctx.api.spec(ref)
68
+ text = (data.get("signature") or "").strip()
69
+ if not text:
70
+ raise UsageError(
71
+ f"{ref} has no signature yet",
72
+ hint="A specification is scaffolded from its signature; this one is empty.",
73
+ )
74
+ # The reference may have arrived without a version; report the resolved one.
75
+ ref = SpecRef(kernel=data.get("kernel", ref.kernel),
76
+ spec=data.get("slug", ref.spec),
77
+ version=data.get("version", ref.version))
78
+
79
+ try:
80
+ parts = [p for p in PARTS if p in wanted] or None
81
+ declaration = parse(text)
82
+ files = emit(declaration, kind="testing", parts=parts)
83
+ except LanguageError as failure:
84
+ diagnostic = failure.diagnostic
85
+ raise CheckFailed(f"{diagnostic.code}: {diagnostic.message}",
86
+ hint=diagnostic.hint) from failure
87
+
88
+ # A bundle scaffolded from a local file is still a bundle: later commands
89
+ # have to recognise it. The declaration names itself, so the marker never
90
+ # depends on having asked the platform.
91
+ marker = str(ref) if ref else reference(declaration)
92
+
93
+ root = (out or Path(_directory(ref, signature))).resolve()
94
+ written, kept = _write(root, files, force=force, update=update)
95
+
96
+ project.write(root, kind="testing", spec=marker, fingerprint=_fingerprint(text))
97
+
98
+ output.ok(f"{'updated' if update else 'scaffolded'} {root}")
99
+ for entry in written:
100
+ output.note(f" {entry}")
101
+ if kept:
102
+ output.note(f" kept: {', '.join(kept)}")
103
+
104
+ if not update:
105
+ output.say("")
106
+ if parts:
107
+ rest = [f"--{p}" for p in PARTS if p not in parts]
108
+ output.say(f"Only the {', '.join(parts)}."
109
+ + (f" Add [bold]{' '.join(rest)}[/bold] for the rest." if rest else ""))
110
+ else:
111
+ output.say("Write [bold]generate.py[/bold] and [bold]oracle.py[/bold], "
112
+ "then run [bold]fherma testing check[/bold].")
113
+
114
+ output.emit({
115
+ "root": str(root),
116
+ "spec": marker,
117
+ "parts": list(parts or PARTS),
118
+ "written": written,
119
+ "kept": kept,
120
+ })
121
+
122
+
123
+ @group.command(short_help="what the platform thinks of this bundle")
124
+ @click.pass_obj
125
+ def status(ctx) -> None:
126
+ """Show the specification's state and what it is still missing."""
127
+ here = project.require_kind("testing")
128
+ ref = SpecRef.parse(here.spec)
129
+ data = ctx.api.spec(ref)
130
+
131
+ output.say(f"{ref} — {data.get('title', '')}")
132
+ output.note(f"status: {data.get('status', 'unknown')}")
133
+
134
+ signature = (data.get("signature") or "").strip()
135
+ if here.fingerprint and signature and _fingerprint(signature) != here.fingerprint:
136
+ output.bad("the specification's signature has changed since this bundle was made")
137
+ output.note(" run `fherma testing init --update` to refresh the generated files")
138
+
139
+ output.emit(data)
140
+
141
+
142
+ # ── writing ─────────────────────────────────────────────────────────────────
143
+ def _write(root: Path, files, *, force: bool, update: bool) -> tuple[list[str], list[str]]:
144
+ """Lay the bundle out.
145
+
146
+ Two rules, and both matter more than they look. A directory holding work is
147
+ never written into unasked; and an update rewrites only what was generated,
148
+ because the whole point of the split is that authored files are the author's.
149
+ """
150
+ if root.exists() and any(root.iterdir()) and not (force or update):
151
+ raise UsageError(
152
+ f"{root} is not empty",
153
+ hint="Pass --force to write into it, or --update to refresh generated files only.",
154
+ )
155
+
156
+ written: list[str] = []
157
+ kept: list[str] = []
158
+
159
+ for file in files:
160
+ target = root / file.path
161
+ authored = not file.generated
162
+
163
+ if authored and (update or (target.exists() and not force)):
164
+ kept.append(file.path)
165
+ continue
166
+
167
+ target.parent.mkdir(parents=True, exist_ok=True)
168
+ target.write_text(file.content, encoding="utf-8")
169
+ written.append(file.path)
170
+
171
+ for name in ("vectors", "assets"):
172
+ (root / name).mkdir(parents=True, exist_ok=True)
173
+
174
+ return written, kept
175
+
176
+
177
+ def _directory(ref: SpecRef | None, signature: Path | None) -> str:
178
+ if ref:
179
+ return f"{ref.kernel}-{ref.spec}" + (f"-{ref.version}" if ref.version else "")
180
+ return signature.stem if signature else "bundle"
181
+
182
+
183
+ def _fingerprint(signature: str) -> str:
184
+ """What the bundle was made from, so a later command can notice a change."""
185
+ import hashlib
186
+
187
+ return "sha256:" + hashlib.sha256(signature.encode("utf-8")).hexdigest()[:16]
@@ -0,0 +1,164 @@
1
+ """`doctor` and `completion` — commands about the installation itself.
2
+
3
+ These two are registered directly on the root rather than through entry points.
4
+ Everything else is a noun the platform has (`auth`, `testing`); these are about
5
+ the tool on this machine, and they must keep working when the rest does not.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import sys
12
+ from dataclasses import dataclass
13
+
14
+ import click
15
+
16
+ from .. import config, output, toolchain
17
+ from ..errors import CheckFailed
18
+
19
+ #: `ok` — nothing to do. `warn` — works, but something is worth knowing.
20
+ #: `bad` — the toolchain is broken here, and `doctor` exits non-zero.
21
+ OK, WARN, BAD = "ok", "warn", "bad"
22
+
23
+ MARKS = {OK: "[green]✓[/green]", WARN: "[yellow]•[/yellow]", BAD: "[red]✗[/red]"}
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class Check:
28
+ label: str
29
+ status: str
30
+ detail: str
31
+ hint: str | None = None
32
+
33
+
34
+ def _python() -> Check:
35
+ if not toolchain.python_is_supported():
36
+ least = ".".join(str(part) for part in toolchain.MINIMUM_PYTHON)
37
+ return Check("python", BAD, toolchain.python_version(),
38
+ f"FHERMA needs Python {least} or newer.")
39
+ # The interpreter's path goes on the hint line: it is long, it wraps badly,
40
+ # and it is only ever read when the answer is "not the python you think".
41
+ return Check("python", OK, toolchain.python_version(), sys.executable)
42
+
43
+
44
+ def _packages() -> list[Check]:
45
+ checks = []
46
+ for component in toolchain.components():
47
+ if component.installed:
48
+ checks.append(Check(component.name, OK, component.version))
49
+ else:
50
+ # Only reachable when someone installed the two halves separately
51
+ # and got one of them; `pip install fherma` cannot produce it.
52
+ checks.append(Check(component.name, BAD, "not installed",
53
+ f"Run `pip install {component.name}`."))
54
+ return checks
55
+
56
+
57
+ def _commands() -> Check:
58
+ groups = toolchain.groups()
59
+ if not groups:
60
+ return Check("commands", BAD, "none registered",
61
+ "Reinstall: entry points are missing from the metadata.")
62
+ extra = {group.name for group in toolchain.plugins()}
63
+ names = ", ".join(
64
+ f"{group.name} ({group.source})" if group.name in extra else group.name
65
+ for group in groups
66
+ )
67
+ return Check("commands", OK, names)
68
+
69
+
70
+ def _profile(profile: config.Profile) -> list[Check]:
71
+ where = config.CONFIG_FILE if config.CONFIG_FILE.exists() else None
72
+ checks = [Check("profile", OK, f"{profile.name} → {profile.url}",
73
+ None if where else "No config file yet; defaults are in use.")]
74
+ if profile.token:
75
+ checks.append(Check("credentials", OK, "token present"))
76
+ else:
77
+ # Not a failure: `--signature` scaffolding never touches the platform.
78
+ checks.append(Check("credentials", WARN, "no token",
79
+ "Run `fherma auth login` when you need the platform."))
80
+ return checks
81
+
82
+
83
+ def _platform(context) -> Check:
84
+ try:
85
+ who = context.api.whoami()
86
+ except Exception as error: # noqa: BLE001 — doctor reports, never raises
87
+ return Check("platform", BAD, "unreachable", getattr(error, "message", str(error)))
88
+ name = who.get("email") or who.get("name") or "authenticated"
89
+ return Check("platform", OK, name)
90
+
91
+
92
+ @click.command()
93
+ @click.option("--online", is_flag=True,
94
+ help="Also contact the platform and check the token is accepted.")
95
+ @click.pass_obj
96
+ def doctor(context, online: bool) -> None:
97
+ """Check that the toolchain is installed and whole.
98
+
99
+ Offline by default, because the usual reason to run this is that something
100
+ is wrong, and a network timeout would bury the answer.
101
+
102
+ \b
103
+ Exits 1 if anything is broken. A missing token is not broken: scaffolding
104
+ from a local signature never needs one.
105
+ """
106
+ checks = [_python(), *_packages(), _commands(), *_profile(context.profile)]
107
+ if online:
108
+ checks.append(_platform(context))
109
+
110
+ output.emit({
111
+ "checks": [{"name": c.label, "status": c.status, "detail": c.detail} for c in checks],
112
+ "ok": all(c.status != BAD for c in checks),
113
+ **toolchain.summary(),
114
+ })
115
+
116
+ width = max(len(check.label) for check in checks)
117
+ indent = " " * (width + 4) # past the mark, the label and the gap
118
+ for check in checks:
119
+ output.say(f"{MARKS[check.status]} {check.label:<{width}} {check.detail}")
120
+ if check.hint:
121
+ output.note(f"{indent}{check.hint}")
122
+
123
+ broken = [check for check in checks if check.status == BAD]
124
+ if broken:
125
+ raise CheckFailed(
126
+ f"{len(broken)} of {len(checks)} checks failed",
127
+ hint=broken[0].hint or f"Start with: {broken[0].label}.",
128
+ )
129
+
130
+
131
+ @click.command()
132
+ @click.argument("shell", required=False,
133
+ type=click.Choice(["bash", "zsh", "fish"], case_sensitive=False))
134
+ @click.pass_context
135
+ def completion(context: click.Context, shell: str | None) -> None:
136
+ """Print a shell completion script for `fherma`.
137
+
138
+ \b
139
+ Append it to the file your shell already reads:
140
+ fherma completion zsh >> ~/.zshrc
141
+ fherma completion bash >> ~/.bashrc
142
+ fherma completion fish > ~/.config/fish/completions/fherma.fish
143
+
144
+ The shell is guessed from $SHELL when not named.
145
+ """
146
+ from click.shell_completion import get_completion_class
147
+
148
+ shell = (shell or _guess_shell() or "").lower()
149
+ if not shell:
150
+ raise click.UsageError("cannot tell which shell this is; name one: bash, zsh, fish")
151
+
152
+ completion_class = get_completion_class(shell)
153
+ if completion_class is None: # pragma: no cover — Choice has already filtered
154
+ raise click.UsageError(f"no completion support for {shell}")
155
+
156
+ # The root command, not this one: completion is for the whole tool. Reading
157
+ # it off the context avoids importing `cli`, which imports this module.
158
+ root = context.find_root().command
159
+ script = completion_class(root, {}, "fherma", "_FHERMA_COMPLETE").source()
160
+ click.echo(script)
161
+
162
+
163
+ def _guess_shell() -> str | None:
164
+ return os.path.basename(os.environ.get("SHELL", "")) or None