Coverage for src/bayernwerk_client/cli.py: 98%
62 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-17 13:07 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-17 13:07 +0000
1"""Top-level command-line interface for bayernwerk-client.
3 bayernwerk map orders
4 bayernwerk -j map orders
5 bayernwerk map order 2577481104
6 bayernwerk efix installer
7 bayernwerk completion install
9`map` is Mein.Auftragsportal (bayernwerk-netz.de), `efix` is the e-fix
10installer portal (bayernwerk.e-fix.info) - see each service's own
11`cli.py` for its sub-subcommands. Further services register themselves
12here the same way. `completion` (see `completion.py`) isn't a Bayernwerk
13service, just shell-completion setup, registered the same way for
14simplicity.
16`-j`/`--json` is a top-level flag (before the service subcommand), applies
17to every service uniformly: `bayernwerk -j map orders`, not
18`bayernwerk map orders -j` or `bayernwerk map -j orders`.
20`MAP_EMAIL`/`MAP_PASSWORD` can come from three places, checked in this
21order (first one wins, same "don't override what's already there"
22semantics at every step):
241. Real environment variables you already exported yourself.
252. A `.env` file (searched for from the current directory upward) via
26 `python-dotenv`.
273. `~/.config/bayernwerk-client/credentials.toml` - for a persistent
28 per-machine default so you don't need a `.env` in every directory you
29 happen to run the CLI from. Flat top-level `KEY = "value"` pairs for a
30 single account, or named profile tables (e.g. `[dglaser]`, an
31 installer's login name) for several - see `_load_credentials_toml` for
32 exactly how a profile gets selected.
33"""
35from __future__ import annotations
37import argparse
38import os
39import sys
40import tomllib
41from pathlib import Path
43import argcomplete
44from dotenv import find_dotenv, load_dotenv
46from bayernwerk_client.completion import register as register_completion
47from bayernwerk_client.efix.cli import register as register_efix
48from bayernwerk_client.exceptions import BayernwerkClientError
49from bayernwerk_client.map.cli import register as register_map
51CREDENTIALS_TOML_PATH = Path.home() / ".config" / "bayernwerk-client" / "credentials.toml"
52_META_SECTION = "general"
53"""`[general]` isn't a profile - it holds meta-config, currently just `DEFAULT`."""
54_DEFAULT_KEY = "DEFAULT"
57def build_parser() -> argparse.ArgumentParser:
58 parser = argparse.ArgumentParser(
59 prog="bayernwerk",
60 description="CLI fuer inoffizielle Bayernwerk-Onlinedienste.",
61 )
62 parser.add_argument("-j", "--json", action="store_true", help="JSON statt menschenlesbarem Text ausgeben")
63 parser.add_argument(
64 "--profile",
65 default=None,
66 metavar="NAME",
67 help=(
68 "Profil aus ~/.config/bayernwerk-client/credentials.toml waehlen "
69 "(Default: $BAYERNWERK_PROFILE, sonst [general].DEFAULT in der Datei, "
70 "sonst die einzige vorhandene Sektion)"
71 ),
72 )
74 subparsers = parser.add_subparsers(dest="service", required=True)
75 register_map(subparsers)
76 register_efix(subparsers)
77 register_completion(subparsers)
79 return parser
82def _load_credentials_toml(path: Path | None = None, *, profile: str | None = None) -> None:
83 """Fill in any environment variables still missing, read from a TOML
84 file - flat top-level `KEY = "value"` pairs for a single account,
85 and/or named profile tables (e.g. `[dglaser]`) for several. Last
86 resort, most persistent source, so this never overrides a real env
87 var or a `.env` value.
89 Which profile table gets used, in order:
91 1. `profile`, if given (the CLI's `--profile NAME`).
92 2. `$BAYERNWERK_PROFILE`, if set.
93 3. `[general]`'s `DEFAULT` key, if the file has one:
94 `[general]\\nDEFAULT = "dglaser"`.
95 4. The file's only profile table, if it has exactly one - no need to
96 spell out a default when there's nothing to choose between.
97 5. Otherwise none - flat top-level keys (if any) still apply, nothing
98 from any table does. Not an error: this just means "no specific
99 profile configured", same as the file not existing at all.
101 Flat top-level keys always apply regardless of which profile (if any)
102 got selected - profile tables layer on top of them, they don't replace
103 them. Explicitly selecting a profile name (steps 1-3) that doesn't
104 exist in the file *is* an error - silently ignoring it would be
105 confusing when you've clearly asked for a specific account. `[general]`
106 itself is never treated as a profile table.
107 """
108 path = path if path is not None else CREDENTIALS_TOML_PATH
109 if not path.exists():
110 return
111 with path.open("rb") as f:
112 data = tomllib.load(f)
114 profile_tables = {key: value for key, value in data.items() if isinstance(value, dict) and key != _META_SECTION}
116 selected = profile or os.environ.get("BAYERNWERK_PROFILE")
117 if selected is None:
118 general = data.get(_META_SECTION)
119 if isinstance(general, dict) and isinstance(general.get(_DEFAULT_KEY), str):
120 selected = general[_DEFAULT_KEY]
121 elif len(profile_tables) == 1:
122 selected = next(iter(profile_tables))
124 candidates: dict[str, object] = {key: value for key, value in data.items() if isinstance(value, str)}
125 if selected is not None:
126 section = profile_tables.get(selected)
127 if section is None:
128 raise BayernwerkClientError(f"Profil '{selected}' nicht gefunden in {path}")
129 candidates.update(section)
131 for key, value in candidates.items():
132 if isinstance(value, str) and key not in os.environ:
133 os.environ[key] = value
136def main(argv: list[str] | None = None) -> int:
137 # find_dotenv(usecwd=True): bare load_dotenv() (no dotenv_path) calls
138 # find_dotenv() with its *default* usecwd=False, which walks up from the
139 # *calling module's* file location (stack introspection), not the
140 # process's actual working directory - harmless when running from a
141 # source checkout (this file's path IS under the project), but flat-out
142 # wrong once installed via `uv tool install`/pip: it would search
143 # upward from the installed package's location in some venv, never
144 # finding a .env in whatever directory the user is actually running the
145 # command from.
146 load_dotenv(find_dotenv(usecwd=True))
147 parser = build_parser()
148 # Don't offer -h/-j/--help/--json until the user actually types "-" -
149 # otherwise they're alphabetically interleaved with the subcommands
150 # (map, efix, completion, --help, --json, ...) in every completion list,
151 # at every nesting level, which is confusing clutter for the common case.
152 argcomplete.autocomplete(parser, always_complete_options=False)
153 args = parser.parse_args(argv)
154 try:
155 # After parse_args (not before, unlike load_dotenv above) - needs
156 # args.profile, and an unknown --profile should surface as the same
157 # kind of clean "Fehler: ..." message as any other CLI error.
158 _load_credentials_toml(profile=args.profile)
159 args.func(args)
160 except BayernwerkClientError as exc:
161 print(f"Fehler: {exc}", file=sys.stderr)
162 return 1
163 return 0
166if __name__ == "__main__":
167 sys.exit(main())