ldap-cli 0.2.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.
ldapc/cli.py ADDED
@@ -0,0 +1,490 @@
1
+ """CLI entry point for ldapc.
2
+
3
+ Provides the argument parser and main entry point for the ldapc
4
+ command-line tool. Uses subcommands for LDAP operations:
5
+
6
+ ldapc list users|groups
7
+ ldapc search user|group <term>
8
+ ldapc get user|group <name>
9
+ ldapc add user|group <name>
10
+ ldapc delete user|group <name>
11
+ ldapc user add group <user> <group>
12
+ ldapc user remove group <user> <group>
13
+ ldapc configure
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import logging
20
+ import os
21
+ import sys
22
+ from pathlib import Path
23
+
24
+ from ldapc import __version__
25
+ from ldapc.exceptions import (
26
+ AuthenticationError,
27
+ ConfigError,
28
+ ConnectionError,
29
+ )
30
+
31
+ LOG = logging.getLogger("ldapc")
32
+
33
+ ENV_SKIP_SSL_VERIFY = "LDAPC_SKIP_SSL_VERIFY"
34
+
35
+
36
+ def _env_skip_ssl() -> bool:
37
+ """Check if SSL verification should be skipped via environment variable."""
38
+ return os.environ.get(ENV_SKIP_SSL_VERIFY, "").lower() in ("1", "true", "yes")
39
+
40
+
41
+ def build_parser() -> argparse.ArgumentParser:
42
+ """Construct the argument parser with subcommands."""
43
+ # Shared flags available on all subcommands
44
+ shared = argparse.ArgumentParser(add_help=False)
45
+ shared.add_argument(
46
+ "--debug",
47
+ action="store_true",
48
+ default=None,
49
+ help="Enable debug logging for troubleshooting.",
50
+ )
51
+ shared.add_argument(
52
+ "--skip-ssl-verify",
53
+ action="store_true",
54
+ default=None,
55
+ help=f"Skip TLS certificate verification (env: {ENV_SKIP_SSL_VERIFY}).",
56
+ )
57
+ shared.add_argument(
58
+ "--json",
59
+ action="store_true",
60
+ default=None,
61
+ help="Output results as JSON.",
62
+ )
63
+ shared.add_argument(
64
+ "--yaml",
65
+ action="store_true",
66
+ default=None,
67
+ help="Output results as YAML (simplified, human-friendly).",
68
+ )
69
+
70
+ parser = argparse.ArgumentParser(
71
+ prog="ldapc",
72
+ description="Query and manage LDAP directories.",
73
+ )
74
+
75
+ parser.add_argument(
76
+ "--version",
77
+ action="version",
78
+ version=f"ldapc {__version__}",
79
+ )
80
+
81
+ parser.add_argument(
82
+ "--docs",
83
+ nargs="?",
84
+ const="",
85
+ default=None,
86
+ metavar="FILE",
87
+ help="View bundled documentation. Without FILE, lists available docs. With FILE, renders it.",
88
+ )
89
+
90
+ parser.add_argument(
91
+ "--page",
92
+ action="store_true",
93
+ help="Enable paging when viewing docs (use with --docs).",
94
+ )
95
+
96
+ parser.add_argument(
97
+ "--markdown",
98
+ action="store_true",
99
+ help="Render docs as raw markdown instead of rich text (use with --docs).",
100
+ )
101
+
102
+ subparsers = parser.add_subparsers(dest="command")
103
+
104
+ # ldapc configure
105
+ subparsers.add_parser("configure", parents=[shared], help="Set up host, bind DN, base DN, and password.")
106
+
107
+ # ldapc list users|groups
108
+ list_parser = subparsers.add_parser("list", parents=[shared], help="List all users or groups.")
109
+ list_parser.add_argument("type", choices=["users", "groups"], help="What to list.")
110
+
111
+ # ldapc search user|group <term>
112
+ search_parser = subparsers.add_parser("search", parents=[shared], help="Search for users or groups.")
113
+ search_parser.add_argument("type", choices=["user", "group"], help="What to search.")
114
+ search_parser.add_argument("term", help="Search term (case-insensitive substring match).")
115
+
116
+ # ldapc get user|group <name>
117
+ get_parser = subparsers.add_parser("get", parents=[shared], help="Get details of a user or group.")
118
+ get_parser.add_argument("type", choices=["user", "group"], help="What to get.")
119
+ get_parser.add_argument("name", help="Exact cn or uid to look up.")
120
+
121
+ # ldapc add user|group <name>
122
+ add_parser = subparsers.add_parser("add", parents=[shared], help="Add a user or group.")
123
+ add_parser.add_argument("type", choices=["user", "group"], help="What to add.")
124
+ add_parser.add_argument("name", help="Name of the user or group to create.")
125
+
126
+ # ldapc delete user|group <name>
127
+ delete_parser = subparsers.add_parser("delete", parents=[shared], help="Delete a user or group.")
128
+ delete_parser.add_argument("type", choices=["user", "group"], help="What to delete.")
129
+ delete_parser.add_argument("name", help="Name of the user or group to delete.")
130
+
131
+ # ldapc user add|remove group <user> <group>
132
+ user_parser = subparsers.add_parser("user", parents=[shared], help="Manage user group membership.")
133
+ user_sub = user_parser.add_subparsers(dest="action")
134
+
135
+ user_add_parser = user_sub.add_parser("add", parents=[shared], help="Add user to a group.")
136
+ user_add_parser.add_argument("target", choices=["group"], help="Must be 'group'.")
137
+ user_add_parser.add_argument("user", help="User to add.")
138
+ user_add_parser.add_argument("group", help="Group to add the user to.")
139
+
140
+ user_remove_parser = user_sub.add_parser("remove", parents=[shared], help="Remove user from a group.")
141
+ user_remove_parser.add_argument("target", choices=["group"], help="Must be 'group'.")
142
+ user_remove_parser.add_argument("user", help="User to remove.")
143
+ user_remove_parser.add_argument("group", help="Group to remove the user from.")
144
+
145
+ return parser
146
+
147
+
148
+ def _get_docs_dir() -> Path:
149
+ """Locate the docs directory bundled with the installed package.
150
+
151
+ Searches the package's installed location for .md files at the
152
+ top level and in a docs/ subdirectory.
153
+
154
+ Returns:
155
+ Path to the package's site-packages directory.
156
+ """
157
+ import importlib.resources
158
+
159
+ # The package root in site-packages
160
+ package_dir = Path(__file__).resolve().parent.parent
161
+ return package_dir
162
+
163
+
164
+ def _find_doc_files() -> list[str]:
165
+ """Find all .md files bundled with the package."""
166
+ base = _get_docs_dir()
167
+ md_files: list[str] = []
168
+
169
+ # Top-level .md files
170
+ for f in sorted(base.glob("*.md")):
171
+ md_files.append(f.name)
172
+
173
+ # docs/ subdirectory
174
+ docs_dir = base / "docs"
175
+ if docs_dir.is_dir():
176
+ for f in sorted(docs_dir.glob("*.md")):
177
+ md_files.append(f"docs/{f.name}")
178
+
179
+ return md_files
180
+
181
+
182
+ def _resolve_doc_path(filename: str) -> Path | None:
183
+ """Resolve a doc filename to its full path."""
184
+ base = _get_docs_dir()
185
+ candidate = base / filename
186
+ if candidate.is_file():
187
+ return candidate
188
+ return None
189
+
190
+
191
+ def _handle_docs(filename: str | None, page: bool, markdown: bool) -> None:
192
+ """Handle the --docs flag.
193
+
194
+ If filename is empty/None, list available docs.
195
+ Otherwise render the specified file.
196
+ """
197
+ if not filename:
198
+ # List available docs
199
+ files = _find_doc_files()
200
+ if not files:
201
+ print("No documentation files found.")
202
+ sys.exit(1)
203
+ print("Available documentation:")
204
+ print()
205
+ for f in files:
206
+ print(f" {f}")
207
+ print()
208
+ print("Usage: ldapc --docs <filename>")
209
+ return
210
+
211
+ doc_path = _resolve_doc_path(filename)
212
+ if doc_path is None:
213
+ print(f"Documentation file not found: {filename}", file=sys.stderr)
214
+ print("Run 'ldapc --docs' to see available files.", file=sys.stderr)
215
+ sys.exit(1)
216
+
217
+ content = doc_path.read_text(encoding="utf-8")
218
+
219
+ if markdown:
220
+ # Raw markdown output
221
+ if page:
222
+ _page_text(content)
223
+ else:
224
+ print(content)
225
+ else:
226
+ # Rich rendered output
227
+ _render_rich(content, page)
228
+
229
+
230
+ def _render_rich(content: str, page: bool) -> None:
231
+ """Render markdown content using rich."""
232
+ from rich.console import Console
233
+ from rich.markdown import Markdown
234
+
235
+ md = Markdown(content)
236
+
237
+ if page:
238
+ with Console() as console:
239
+ with console.pager(styles=True):
240
+ console.print(md)
241
+ else:
242
+ console = Console()
243
+ console.print(md)
244
+
245
+
246
+ def _page_text(text: str) -> None:
247
+ """Display text with paging."""
248
+ import pydoc
249
+ pydoc.pager(text)
250
+
251
+
252
+ def _get_client(skip_ssl_verify: bool):
253
+ """Load config and return a connected LdapClient."""
254
+ from ldapc.config import load_config, retrieve_password
255
+ from ldapc.ldap_client import LdapClient
256
+
257
+ config = load_config()
258
+ password = retrieve_password(config.username)
259
+
260
+ LOG.debug("Connecting to %s as %s", config.host, config.username)
261
+ LOG.debug("Base DN: %s", config.base_dn)
262
+ if skip_ssl_verify:
263
+ LOG.debug("TLS certificate verification disabled")
264
+
265
+ client = LdapClient(config.host, config.username, password, skip_ssl_verify=skip_ssl_verify)
266
+ return client, config
267
+
268
+
269
+ def _handle_configure() -> None:
270
+ """Run the interactive configuration flow."""
271
+ from ldapc.config import save_config, store_password, LdapcConfig
272
+ import getpass
273
+
274
+ host = input("LDAP host URL: ")
275
+ base_dn = input("Base DN: ")
276
+ username = input("Bind DN: ")
277
+ users_ou = input("Users container RDN [cn=users]: ").strip() or "cn=users"
278
+ groups_ou = input("Groups container RDN [cn=groups]: ").strip() or "cn=groups"
279
+ password = getpass.getpass("Bind password: ")
280
+
281
+ config = LdapcConfig(host=host, username=username, base_dn=base_dn,
282
+ users_ou=users_ou, groups_ou=groups_ou)
283
+ save_config(config)
284
+ store_password(username, password)
285
+ LOG.debug("Configuration saved to ~/.ldapc/config.yaml")
286
+ print("Configuration saved.")
287
+
288
+
289
+ def _handle_list(args) -> None:
290
+ """Handle: ldapc list users|groups"""
291
+ from ldapc.formatter import (
292
+ format_user_entries, format_group_entries, format_entries_json,
293
+ format_user_entries_yaml, format_group_entries_yaml,
294
+ )
295
+
296
+ client, config = _get_client(args.skip_ssl_verify)
297
+ with client:
298
+ if args.type == "users":
299
+ entries = client.list_users(config.base_dn)
300
+ else:
301
+ entries = client.list_groups(config.base_dn)
302
+
303
+ LOG.debug("List returned %d entries", len(entries))
304
+
305
+ if not entries:
306
+ print(f"No {args.type} found.")
307
+ return
308
+
309
+ if args.json:
310
+ print(format_entries_json(entries))
311
+ elif args.yaml:
312
+ if args.type == "users":
313
+ print(format_user_entries_yaml(entries))
314
+ else:
315
+ print(format_group_entries_yaml(entries))
316
+ elif args.type == "users":
317
+ print(format_user_entries(entries))
318
+ else:
319
+ print(format_group_entries(entries))
320
+
321
+
322
+ def _handle_search(args) -> None:
323
+ """Handle: ldapc search user|group <term>"""
324
+ from ldapc.formatter import (
325
+ format_user_entries, format_group_entries, format_entries_json,
326
+ format_user_entries_yaml, format_group_entries_yaml,
327
+ )
328
+
329
+ client, config = _get_client(args.skip_ssl_verify)
330
+ with client:
331
+ if args.type == "user":
332
+ entries = client.search_users(args.term, config.base_dn)
333
+ else:
334
+ entries = client.search_groups(args.term, config.base_dn)
335
+
336
+ LOG.debug("Search returned %d entries", len(entries))
337
+
338
+ if not entries:
339
+ print(f"No results found for '{args.term}'")
340
+ return
341
+
342
+ if args.json:
343
+ print(format_entries_json(entries))
344
+ elif args.yaml:
345
+ if args.type == "user":
346
+ print(format_user_entries_yaml(entries))
347
+ else:
348
+ print(format_group_entries_yaml(entries))
349
+ elif args.type == "user":
350
+ print(format_user_entries(entries))
351
+ else:
352
+ print(format_group_entries(entries))
353
+
354
+
355
+ def _handle_get(args) -> None:
356
+ """Handle: ldapc get user|group <name>"""
357
+ from ldapc.formatter import (
358
+ format_user_entries, format_group_entries, format_entries_json,
359
+ format_user_entries_yaml, format_group_entries_yaml,
360
+ )
361
+
362
+ client, config = _get_client(args.skip_ssl_verify)
363
+ with client:
364
+ if args.type == "user":
365
+ entries = client.get_user(args.name, config.base_dn)
366
+ else:
367
+ entries = client.get_group(args.name, config.base_dn)
368
+
369
+ if not entries:
370
+ print(f"No {args.type} found: '{args.name}'")
371
+ sys.exit(1)
372
+
373
+ if args.json:
374
+ print(format_entries_json(entries))
375
+ elif args.yaml:
376
+ if args.type == "user":
377
+ print(format_user_entries_yaml(entries))
378
+ else:
379
+ print(format_group_entries_yaml(entries))
380
+ elif args.type == "user":
381
+ print(format_user_entries(entries))
382
+ else:
383
+ print(format_group_entries(entries))
384
+
385
+
386
+ def _handle_add(args) -> None:
387
+ """Handle: ldapc add user|group <name>"""
388
+ client, config = _get_client(args.skip_ssl_verify)
389
+ with client:
390
+ if args.type == "user":
391
+ client.add_user(args.name, config.base_dn, config.users_ou)
392
+ else:
393
+ client.add_group(args.name, config.base_dn, config.groups_ou)
394
+
395
+ print(f"{args.type.capitalize()} '{args.name}' added.")
396
+
397
+
398
+ def _handle_delete(args) -> None:
399
+ """Handle: ldapc delete user|group <name>"""
400
+ client, config = _get_client(args.skip_ssl_verify)
401
+ with client:
402
+ if args.type == "user":
403
+ client.delete_user(args.name, config.base_dn, config.users_ou)
404
+ else:
405
+ client.delete_group(args.name, config.base_dn, config.groups_ou)
406
+
407
+ print(f"{args.type.capitalize()} '{args.name}' deleted.")
408
+
409
+
410
+ def _handle_user_group(args) -> None:
411
+ """Handle: ldapc user add|remove group <user> <group>"""
412
+ client, config = _get_client(args.skip_ssl_verify)
413
+ with client:
414
+ if args.action == "add":
415
+ client.add_user_to_group(args.user, args.group, config.base_dn,
416
+ config.users_ou, config.groups_ou)
417
+ print(f"User '{args.user}' added to group '{args.group}'.")
418
+ elif args.action == "remove":
419
+ client.remove_user_from_group(args.user, args.group, config.base_dn,
420
+ config.users_ou, config.groups_ou)
421
+ print(f"User '{args.user}' removed from group '{args.group}'.")
422
+
423
+
424
+ def main() -> None:
425
+ """Entry point registered as console script `ldapc`.
426
+
427
+ Parses arguments, dispatches to the appropriate handler, and maps
428
+ exceptions to exit codes:
429
+ - ConfigError → exit 2
430
+ - ConnectionError → exit 1
431
+ - AuthenticationError → exit 1
432
+ """
433
+ parser = build_parser()
434
+
435
+ if len(sys.argv) == 1:
436
+ parser.print_help(sys.stdout)
437
+ sys.exit(0)
438
+
439
+ args = parser.parse_args()
440
+
441
+ # Handle --docs before anything else (top-level flag)
442
+ if args.docs is not None:
443
+ _handle_docs(args.docs, args.page, args.markdown)
444
+ sys.exit(0)
445
+
446
+ # Resolve boolean flags (None means not explicitly set)
447
+ args.debug = bool(args.debug)
448
+ args.json = bool(args.json)
449
+ args.yaml = bool(args.yaml)
450
+ args.skip_ssl_verify = bool(args.skip_ssl_verify) or _env_skip_ssl()
451
+
452
+ if args.debug:
453
+ logging.basicConfig(
454
+ level=logging.DEBUG,
455
+ format="%(name)s %(levelname)s: %(message)s",
456
+ stream=sys.stderr,
457
+ )
458
+ else:
459
+ logging.basicConfig(
460
+ level=logging.WARNING,
461
+ format="%(name)s %(levelname)s: %(message)s",
462
+ stream=sys.stderr,
463
+ )
464
+
465
+ try:
466
+ if args.command == "configure":
467
+ _handle_configure()
468
+ elif args.command == "list":
469
+ _handle_list(args)
470
+ elif args.command == "search":
471
+ _handle_search(args)
472
+ elif args.command == "get":
473
+ _handle_get(args)
474
+ elif args.command == "add":
475
+ _handle_add(args)
476
+ elif args.command == "delete":
477
+ _handle_delete(args)
478
+ elif args.command == "user":
479
+ if not args.action:
480
+ parser.parse_args(["user", "--help"])
481
+ _handle_user_group(args)
482
+ else:
483
+ parser.print_help(sys.stdout)
484
+ sys.exit(0)
485
+ except ConfigError as exc:
486
+ print(f"Error: {exc}", file=sys.stderr)
487
+ sys.exit(2)
488
+ except (ConnectionError, AuthenticationError) as exc:
489
+ print(f"Error: {exc}", file=sys.stderr)
490
+ sys.exit(1)
ldapc/config.py ADDED
@@ -0,0 +1,184 @@
1
+ """Configuration management for ldapc.
2
+
3
+ Handles reading, writing, and validating the ldapc configuration file
4
+ stored at ~/.ldapc/config.yaml. Provides serialization between
5
+ LdapcConfig dataclass instances and YAML format.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import stat
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+
15
+ import keyring
16
+ import yaml
17
+
18
+ from ldapc.exceptions import ConfigError
19
+
20
+ DEFAULT_CONFIG_DIR = Path.home() / ".ldapc"
21
+ DEFAULT_CONFIG_PATH = DEFAULT_CONFIG_DIR / "config.yaml"
22
+
23
+
24
+ @dataclass
25
+ class LdapcConfig:
26
+ """Configuration object for ldapc.
27
+
28
+ Attributes:
29
+ host: LDAP server URL (ldaps:// or ldap://).
30
+ username: Bind DN for authentication.
31
+ base_dn: Base DN for searches. Defaults to empty string.
32
+ users_ou: RDN for the users container. Defaults to "cn=users".
33
+ groups_ou: RDN for the groups container. Defaults to "cn=groups".
34
+ """
35
+
36
+ host: str
37
+ username: str
38
+ base_dn: str = ""
39
+ users_ou: str = "cn=users"
40
+ groups_ou: str = "cn=groups"
41
+
42
+
43
+ def config_to_yaml(config: LdapcConfig) -> str:
44
+ """Serialize a config object to a YAML string.
45
+
46
+ Args:
47
+ config: The LdapcConfig instance to serialize.
48
+
49
+ Returns:
50
+ A YAML-formatted string representing the configuration.
51
+ """
52
+ data = {
53
+ "host": config.host,
54
+ "username": config.username,
55
+ "base_dn": config.base_dn,
56
+ "users_ou": config.users_ou,
57
+ "groups_ou": config.groups_ou,
58
+ }
59
+ return yaml.dump(data, default_flow_style=False, sort_keys=False)
60
+
61
+
62
+ def yaml_to_config(yaml_str: str) -> LdapcConfig:
63
+ """Parse a YAML string into a config object.
64
+
65
+ Args:
66
+ yaml_str: A YAML-formatted string to parse.
67
+
68
+ Returns:
69
+ An LdapcConfig instance populated from the YAML data.
70
+
71
+ Raises:
72
+ ConfigError: If the YAML is invalid or missing required fields.
73
+ """
74
+ try:
75
+ data = yaml.safe_load(yaml_str)
76
+ except yaml.YAMLError as exc:
77
+ raise ConfigError(f"Invalid YAML in configuration: {exc}") from exc
78
+
79
+ if not isinstance(data, dict):
80
+ raise ConfigError(
81
+ "Invalid configuration file: expected a YAML mapping, "
82
+ f"got {type(data).__name__}"
83
+ )
84
+
85
+ missing_fields = []
86
+ for field in ("host", "username"):
87
+ if field not in data:
88
+ missing_fields.append(field)
89
+
90
+ if missing_fields:
91
+ raise ConfigError(
92
+ f"Missing required configuration fields: {', '.join(missing_fields)}"
93
+ )
94
+
95
+ return LdapcConfig(
96
+ host=str(data["host"]),
97
+ username=str(data["username"]),
98
+ base_dn=str(data.get("base_dn", "")),
99
+ users_ou=str(data.get("users_ou", "cn=users")),
100
+ groups_ou=str(data.get("groups_ou", "cn=groups")),
101
+ )
102
+
103
+
104
+ def load_config(config_path: Path | None = None) -> LdapcConfig:
105
+ """Load configuration from ~/.ldapc/config.yaml.
106
+
107
+ Args:
108
+ config_path: Optional path to the config file. Defaults to
109
+ ~/.ldapc/config.yaml.
110
+
111
+ Returns:
112
+ An LdapcConfig instance loaded from the file.
113
+
114
+ Raises:
115
+ ConfigError: If the file is missing or contains invalid YAML.
116
+ """
117
+ path = config_path or DEFAULT_CONFIG_PATH
118
+
119
+ if not path.exists():
120
+ raise ConfigError(
121
+ "Configuration not found. Run 'ldapc configure' to set up."
122
+ )
123
+
124
+ try:
125
+ yaml_str = path.read_text(encoding="utf-8")
126
+ except OSError as exc:
127
+ raise ConfigError(f"Unable to read configuration file: {exc}") from exc
128
+
129
+ return yaml_to_config(yaml_str)
130
+
131
+
132
+ def save_config(config: LdapcConfig, config_path: Path | None = None) -> None:
133
+ """Serialize config to ~/.ldapc/config.yaml with 0600 permissions.
134
+
135
+ Creates the ~/.ldapc/ directory if it does not exist.
136
+
137
+ Args:
138
+ config: The LdapcConfig instance to persist.
139
+ config_path: Optional path to the config file. Defaults to
140
+ ~/.ldapc/config.yaml.
141
+ """
142
+ path = config_path or DEFAULT_CONFIG_PATH
143
+ config_dir = path.parent
144
+
145
+ config_dir.mkdir(parents=True, exist_ok=True)
146
+
147
+ yaml_str = config_to_yaml(config)
148
+ path.write_text(yaml_str, encoding="utf-8")
149
+ os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
150
+
151
+
152
+ KEYRING_SERVICE = "ldapc"
153
+
154
+
155
+ def store_password(username: str, password: str) -> None:
156
+ """Store password in OS keychain under service 'ldapc'.
157
+
158
+ Args:
159
+ username: The account identifier (bind DN) to associate with
160
+ the stored password.
161
+ password: The password to store securely.
162
+ """
163
+ keyring.set_password(KEYRING_SERVICE, username, password)
164
+
165
+
166
+ def retrieve_password(username: str) -> str:
167
+ """Retrieve password from OS keychain.
168
+
169
+ Args:
170
+ username: The account identifier (bind DN) whose password
171
+ should be retrieved.
172
+
173
+ Returns:
174
+ The stored password string.
175
+
176
+ Raises:
177
+ ConfigError: If no password is stored for the account.
178
+ """
179
+ password = keyring.get_password(KEYRING_SERVICE, username)
180
+ if password is None:
181
+ raise ConfigError(
182
+ "No stored password. Run 'ldapc configure' to set up."
183
+ )
184
+ return password
ldapc/exceptions.py ADDED
@@ -0,0 +1,37 @@
1
+ """Exception classes for ldapc.
2
+
3
+ All exceptions inherit from LdapcError, enabling consistent catching
4
+ at the CLI layer and mapping to appropriate exit codes.
5
+ """
6
+
7
+
8
+ class LdapcError(Exception):
9
+ """Base exception for ldapc.
10
+
11
+ All ldapc-specific exceptions inherit from this class so the CLI
12
+ entry point can catch them uniformly and map to exit codes.
13
+ """
14
+
15
+
16
+ class ConfigError(LdapcError):
17
+ """Configuration-related errors.
18
+
19
+ Raised when the configuration file is missing, contains invalid YAML,
20
+ or required fields are absent. Maps to exit code 2.
21
+ """
22
+
23
+
24
+ class ConnectionError(LdapcError):
25
+ """LDAP connection errors.
26
+
27
+ Raised when the LDAP server is unreachable, the connection times out,
28
+ or TLS certificate verification fails. Maps to exit code 1.
29
+ """
30
+
31
+
32
+ class AuthenticationError(LdapcError):
33
+ """LDAP bind/authentication errors.
34
+
35
+ Raised when the LDAP server rejects the bind credentials.
36
+ Maps to exit code 1.
37
+ """