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

1"""Top-level command-line interface for bayernwerk-client. 

2 

3 bayernwerk map orders 

4 bayernwerk -j map orders 

5 bayernwerk map order 2577481104 

6 bayernwerk efix installer 

7 bayernwerk completion install 

8 

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. 

15 

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`. 

19 

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): 

23 

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""" 

34 

35from __future__ import annotations 

36 

37import argparse 

38import os 

39import sys 

40import tomllib 

41from pathlib import Path 

42 

43import argcomplete 

44from dotenv import find_dotenv, load_dotenv 

45 

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 

50 

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" 

55 

56 

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 ) 

73 

74 subparsers = parser.add_subparsers(dest="service", required=True) 

75 register_map(subparsers) 

76 register_efix(subparsers) 

77 register_completion(subparsers) 

78 

79 return parser 

80 

81 

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. 

88 

89 Which profile table gets used, in order: 

90 

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. 

100 

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) 

113 

114 profile_tables = {key: value for key, value in data.items() if isinstance(value, dict) and key != _META_SECTION} 

115 

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)) 

123 

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) 

130 

131 for key, value in candidates.items(): 

132 if isinstance(value, str) and key not in os.environ: 

133 os.environ[key] = value 

134 

135 

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 

164 

165 

166if __name__ == "__main__": 

167 sys.exit(main())