rndc-python 0.1.1__tar.gz → 0.2.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: rndc-python
3
- Version: 0.1.1
3
+ Version: 0.2.0
4
4
  Summary: Python client for ISC BIND's RNDC
5
5
  Project-URL: Homepage, https://github.com/davidgroves/rndc-python
6
6
  Project-URL: Repository, https://github.com/davidgroves/rndc-python
@@ -67,10 +67,10 @@ The package includes a CLI tool `rndc-python-cli`:
67
67
  rndc-python-cli -s 127.0.0.1 -p 953 -a sha256 -k <base64-secret> status
68
68
 
69
69
  # Using environment variables
70
- export ZPAPI_RNDC_HOST=127.0.0.1
71
- export ZPAPI_RNDC_PORT=953
72
- export ZPAPI_RNDC_ALGORITHM=sha256
73
- export ZPAPI_RNDC_SECRET=<base64-secret>
70
+ export RNDC_HOST=127.0.0.1
71
+ export RNDC_PORT=953
72
+ export RNDC_ALGORITHM=sha256
73
+ export RNDC_SECRET=<base64-secret>
74
74
  rndc-python-cli status
75
75
 
76
76
  # Mix of both (CLI options override env vars)
@@ -81,13 +81,13 @@ rndc-python-cli --port 954 reload
81
81
 
82
82
  The client can read its settings from environment variables (or a `.env` file):
83
83
 
84
- - `ZPAPI_RNDC_HOST`
85
- - `ZPAPI_RNDC_PORT`
86
- - `ZPAPI_RNDC_ALGORITHM` (e.g. `hmac-sha256`)
87
- - `ZPAPI_RNDC_SECRET`
88
- - `ZPAPI_RNDC_TIMEOUT`
89
- - `ZPAPI_RNDC_MAX_RETRIES`
90
- - `ZPAPI_RNDC_RETRY_DELAY`
84
+ - `RNDC_HOST`
85
+ - `RNDC_PORT`
86
+ - `RNDC_ALGORITHM` (e.g. `hmac-sha256`)
87
+ - `RNDC_SECRET`
88
+ - `RNDC_TIMEOUT`
89
+ - `RNDC_MAX_RETRIES`
90
+ - `RNDC_RETRY_DELAY`
91
91
 
92
92
  You can also configure the client directly in Python:
93
93
 
@@ -27,10 +27,10 @@ The package includes a CLI tool `rndc-python-cli`:
27
27
  rndc-python-cli -s 127.0.0.1 -p 953 -a sha256 -k <base64-secret> status
28
28
 
29
29
  # Using environment variables
30
- export ZPAPI_RNDC_HOST=127.0.0.1
31
- export ZPAPI_RNDC_PORT=953
32
- export ZPAPI_RNDC_ALGORITHM=sha256
33
- export ZPAPI_RNDC_SECRET=<base64-secret>
30
+ export RNDC_HOST=127.0.0.1
31
+ export RNDC_PORT=953
32
+ export RNDC_ALGORITHM=sha256
33
+ export RNDC_SECRET=<base64-secret>
34
34
  rndc-python-cli status
35
35
 
36
36
  # Mix of both (CLI options override env vars)
@@ -41,13 +41,13 @@ rndc-python-cli --port 954 reload
41
41
 
42
42
  The client can read its settings from environment variables (or a `.env` file):
43
43
 
44
- - `ZPAPI_RNDC_HOST`
45
- - `ZPAPI_RNDC_PORT`
46
- - `ZPAPI_RNDC_ALGORITHM` (e.g. `hmac-sha256`)
47
- - `ZPAPI_RNDC_SECRET`
48
- - `ZPAPI_RNDC_TIMEOUT`
49
- - `ZPAPI_RNDC_MAX_RETRIES`
50
- - `ZPAPI_RNDC_RETRY_DELAY`
44
+ - `RNDC_HOST`
45
+ - `RNDC_PORT`
46
+ - `RNDC_ALGORITHM` (e.g. `hmac-sha256`)
47
+ - `RNDC_SECRET`
48
+ - `RNDC_TIMEOUT`
49
+ - `RNDC_MAX_RETRIES`
50
+ - `RNDC_RETRY_DELAY`
51
51
 
52
52
  You can also configure the client directly in Python:
53
53
 
@@ -1,10 +1,10 @@
1
1
  [build-system]
2
- requires = ["hatchling>=1.25"]
2
+ requires = ["hatchling>=1.25", "hatch-vcs>=0.4.0"]
3
3
  build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "rndc-python"
7
- version = "0.1.1"
7
+ dynamic = ["version"]
8
8
  description = "Python client for ISC BIND's RNDC"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -54,6 +54,9 @@ Homepage = "https://github.com/davidgroves/rndc-python"
54
54
  Repository = "https://github.com/davidgroves/rndc-python"
55
55
  Issues = "https://github.com/davidgroves/rndc-python/issues"
56
56
 
57
+ [tool.hatch.version]
58
+ source = "vcs"
59
+
57
60
  [tool.hatch.build.targets.wheel]
58
61
  packages = ["src/rndc_python"]
59
62
 
@@ -103,7 +106,8 @@ select = [
103
106
  "B", # flake8-bugbear
104
107
  "C4", # flake8-comprehensions
105
108
  "UP", # pyupgrade
106
- "SIM", # flake8-simplify
109
+ "SIM", # flake8-simple
110
+ "BLE001", # blind except Exception — use sparingly with noqa + comment where intentional
107
111
  ]
108
112
  ignore = [
109
113
  "E501", # line too long (handled by formatter)
@@ -4,7 +4,13 @@ rndc-python - A Python client library for ISC BIND's RNDC
4
4
  This library provides a Python interface to ISC BIND's Remote Name Daemon Control (RNDC).
5
5
  """
6
6
 
7
- __version__ = "0.1.1"
7
+ from importlib.metadata import version
8
+
9
+ try:
10
+ __version__ = version("rndc-python")
11
+ except Exception: # noqa: BLE001 — zpapi-style fallback when package metadata is unavailable
12
+ __version__ = "0.0.0+unknown"
13
+
8
14
  __author__ = "David Groves"
9
15
  __email__ = "dave@fibrecat.org"
10
16
 
@@ -5,6 +5,7 @@ Usage:
5
5
  rndc-python-cli [options] <command>
6
6
 
7
7
  Examples:
8
+ rndc-python-cli --version
8
9
  rndc-python-cli status
9
10
  rndc-python-cli reload
10
11
  rndc-python-cli zonestatus example.com
@@ -17,6 +18,8 @@ import sys
17
18
 
18
19
  import click
19
20
 
21
+ from . import __version__
22
+ from .config import ENV_ALGORITHM, ENV_HOST, ENV_PORT, ENV_SECRET, ENV_TIMEOUT
20
23
  from .enums import TSIGAlgorithm
21
24
 
22
25
  # Use lowercase enum names for CLI choices
@@ -26,37 +29,38 @@ ALGORITHM_CHOICES += [f"hmac-{alg.name.lower()}" for alg in TSIGAlgorithm]
26
29
 
27
30
 
28
31
  @click.command()
32
+ @click.version_option(__version__, "-V", "--version", prog_name="rndc-python-cli")
29
33
  @click.option(
30
34
  "-s",
31
35
  "--host",
32
- envvar="ZPAPI_RNDC_HOST",
36
+ envvar=ENV_HOST,
33
37
  help="RNDC server hostname or IP",
34
38
  )
35
39
  @click.option(
36
40
  "-p",
37
41
  "--port",
38
42
  type=int,
39
- envvar="ZPAPI_RNDC_PORT",
43
+ envvar=ENV_PORT,
40
44
  help="RNDC server port",
41
45
  )
42
46
  @click.option(
43
47
  "-a",
44
48
  "--algorithm",
45
49
  type=click.Choice(ALGORITHM_CHOICES, case_sensitive=False),
46
- envvar="ZPAPI_RNDC_ALGORITHM",
50
+ envvar=ENV_ALGORITHM,
47
51
  help="TSIG algorithm",
48
52
  )
49
53
  @click.option(
50
54
  "-k",
51
55
  "--secret",
52
- envvar="ZPAPI_RNDC_SECRET",
56
+ envvar=ENV_SECRET,
53
57
  help="Base64-encoded RNDC secret key",
54
58
  )
55
59
  @click.option(
56
60
  "-t",
57
61
  "--timeout",
58
62
  type=int,
59
- envvar="ZPAPI_RNDC_TIMEOUT",
63
+ envvar=ENV_TIMEOUT,
60
64
  default=10,
61
65
  help="Connection timeout in seconds",
62
66
  )
@@ -79,15 +83,13 @@ def main(
79
83
 
80
84
  # Validate required options
81
85
  if not host:
82
- raise click.ClickException("Missing --host or ZPAPI_RNDC_HOST environment variable")
86
+ raise click.ClickException(f"Missing --host or {ENV_HOST} environment variable")
83
87
  if not port:
84
- raise click.ClickException("Missing --port or ZPAPI_RNDC_PORT environment variable")
88
+ raise click.ClickException(f"Missing --port or {ENV_PORT} environment variable")
85
89
  if not algorithm:
86
- raise click.ClickException(
87
- "Missing --algorithm or ZPAPI_RNDC_ALGORITHM environment variable"
88
- )
90
+ raise click.ClickException(f"Missing --algorithm or {ENV_ALGORITHM} environment variable")
89
91
  if not secret:
90
- raise click.ClickException("Missing --secret or ZPAPI_RNDC_SECRET environment variable")
92
+ raise click.ClickException(f"Missing --secret or {ENV_SECRET} environment variable")
91
93
 
92
94
  # Build client kwargs
93
95
  client_kwargs: dict = {
@@ -126,7 +128,7 @@ def main(
126
128
  raise click.ClickException(f"Configuration error: {e}") from None
127
129
  except ConnectionError as e:
128
130
  raise click.ClickException(f"Connection error: {e}") from None
129
- except Exception as e:
131
+ except Exception as e: # noqa: BLE001 — surface unexpected errors as CLI failures after specific handlers
130
132
  raise click.ClickException(str(e)) from None
131
133
 
132
134
 
@@ -9,6 +9,14 @@ import os
9
9
 
10
10
  from dotenv import load_dotenv
11
11
 
12
+ ENV_HOST = "RNDC_HOST"
13
+ ENV_PORT = "RNDC_PORT"
14
+ ENV_ALGORITHM = "RNDC_ALGORITHM"
15
+ ENV_SECRET = "RNDC_SECRET"
16
+ ENV_TIMEOUT = "RNDC_TIMEOUT"
17
+ ENV_MAX_RETRIES = "RNDC_MAX_RETRIES"
18
+ ENV_RETRY_DELAY = "RNDC_RETRY_DELAY"
19
+
12
20
 
13
21
  def _load_env_file() -> None:
14
22
  """Load environment variables from .env file if it exists."""
@@ -0,0 +1,204 @@
1
+ """
2
+ Data models for RNDC responses.
3
+
4
+ This module contains typed dataclasses for parsing RNDC command responses.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from dataclasses import dataclass
11
+ from datetime import datetime
12
+ from typing import ClassVar
13
+
14
+
15
+ @dataclass
16
+ class Status:
17
+ """Parsed RNDC status response.
18
+
19
+ Represents the status of a BIND DNS server as returned by the 'status' command.
20
+ """
21
+
22
+ # Version and system info
23
+ version: str
24
+ """BIND version string (e.g., 'BIND 9.20.15-2-Debian (Stable Release)')"""
25
+
26
+ running_on: str
27
+ """System information where BIND is running"""
28
+
29
+ boot_time: datetime
30
+ """When the server was started"""
31
+
32
+ last_configured: datetime
33
+ """When the server was last configured/reloaded"""
34
+
35
+ configuration_file: str
36
+ """Path to the named.conf configuration file"""
37
+
38
+ # Resource info
39
+ cpus_found: int
40
+ """Number of CPUs detected"""
41
+
42
+ worker_threads: int
43
+ """Number of worker threads"""
44
+
45
+ # Zone info
46
+ number_of_zones: int
47
+ """Total number of zones"""
48
+
49
+ automatic_zones: int
50
+ """Number of automatic zones (built-in zones like localhost)"""
51
+
52
+ # Debug and logging
53
+ debug_level: int
54
+ """Current debug level (0 = off)"""
55
+
56
+ query_logging: bool
57
+ """Whether query logging is enabled"""
58
+
59
+ response_logging: bool
60
+ """Whether response logging is enabled"""
61
+
62
+ memory_profiling_active: bool
63
+ """Whether memory profiling is active"""
64
+
65
+ # Transfer status
66
+ xfers_running: int
67
+ """Number of zone transfers currently running"""
68
+
69
+ xfers_deferred: int
70
+ """Number of zone transfers deferred"""
71
+
72
+ xfers_first_refresh: int
73
+ """Number of zones waiting for first refresh"""
74
+
75
+ soa_queries_in_progress: int
76
+ """Number of SOA queries in progress"""
77
+
78
+ # Client connections
79
+ recursive_clients_current: int
80
+ """Current number of recursive clients"""
81
+
82
+ recursive_clients_soft_limit: int
83
+ """Soft limit for recursive clients"""
84
+
85
+ recursive_clients_hard_limit: int
86
+ """Hard limit for recursive clients"""
87
+
88
+ recursive_high_water: int
89
+ """High water mark for recursive clients"""
90
+
91
+ tcp_clients_current: int
92
+ """Current number of TCP clients"""
93
+
94
+ tcp_clients_limit: int
95
+ """Limit for TCP clients"""
96
+
97
+ tcp_high_water: int
98
+ """High water mark for TCP clients"""
99
+
100
+ # Server state
101
+ server_is_up: bool
102
+ """Whether the server is up and running"""
103
+
104
+ # Raw text for any unparsed fields
105
+ raw_text: str
106
+ """The raw status text from RNDC"""
107
+
108
+ # Date format used by BIND
109
+ _DATE_FORMAT: ClassVar[str] = "%a, %d %b %Y %H:%M:%S %Z"
110
+
111
+ @classmethod
112
+ def from_text(cls, text: str) -> Status:
113
+ """Parse status text into a Status object.
114
+
115
+ Args:
116
+ text: Raw status text from RNDC status command
117
+
118
+ Returns:
119
+ Parsed Status object
120
+ """
121
+
122
+ def get_value(pattern: str, default: str = "") -> str:
123
+ """Extract a value using regex pattern."""
124
+ match = re.search(pattern, text, re.MULTILINE)
125
+ return match.group(1).strip() if match else default
126
+
127
+ def get_int(pattern: str, default: int = 0) -> int:
128
+ """Extract an integer value using regex pattern."""
129
+ value = get_value(pattern)
130
+ try:
131
+ return int(value) if value else default
132
+ except ValueError:
133
+ return default
134
+
135
+ def parse_datetime(pattern: str) -> datetime:
136
+ """Parse a datetime value using regex pattern."""
137
+ value = get_value(pattern)
138
+ if not value:
139
+ return datetime.min
140
+ try:
141
+ return datetime.strptime(value, cls._DATE_FORMAT)
142
+ except ValueError:
143
+ return datetime.min
144
+
145
+ def parse_bool_on_off(pattern: str) -> bool:
146
+ """Parse an ON/OFF boolean value."""
147
+ value = get_value(pattern).upper()
148
+ return value == "ON"
149
+
150
+ def parse_bool_active(pattern: str) -> bool:
151
+ """Parse an ACTIVE/INACTIVE boolean value."""
152
+ value = get_value(pattern).upper()
153
+ return value == "ACTIVE"
154
+
155
+ # Parse zones with automatic count
156
+ zones_match = re.search(r"number of zones:\s*(\d+)\s*(?:\((\d+)\s*automatic\))?", text)
157
+ number_of_zones = int(zones_match.group(1)) if zones_match else 0
158
+ automatic_zones = int(zones_match.group(2)) if zones_match and zones_match.group(2) else 0
159
+
160
+ # Parse recursive clients: current/soft/hard
161
+ recursive_match = re.search(r"recursive clients:\s*(\d+)/(\d+)/(\d+)", text)
162
+ if recursive_match:
163
+ recursive_current = int(recursive_match.group(1))
164
+ recursive_soft = int(recursive_match.group(2))
165
+ recursive_hard = int(recursive_match.group(3))
166
+ else:
167
+ recursive_current = recursive_soft = recursive_hard = 0
168
+
169
+ # Parse TCP clients: current/limit
170
+ tcp_match = re.search(r"tcp clients:\s*(\d+)/(\d+)", text, re.IGNORECASE)
171
+ if tcp_match:
172
+ tcp_current = int(tcp_match.group(1))
173
+ tcp_limit = int(tcp_match.group(2))
174
+ else:
175
+ tcp_current = tcp_limit = 0
176
+
177
+ return cls(
178
+ version=get_value(r"^version:\s*(.+)$"),
179
+ running_on=get_value(r"^running on [^:]+:\s*(.+)$"),
180
+ boot_time=parse_datetime(r"^boot time:\s*(.+)$"),
181
+ last_configured=parse_datetime(r"^last configured:\s*(.+)$"),
182
+ configuration_file=get_value(r"^configuration file:\s*(.+)$"),
183
+ cpus_found=get_int(r"^CPUs found:\s*(\d+)"),
184
+ worker_threads=get_int(r"^worker threads:\s*(\d+)"),
185
+ number_of_zones=number_of_zones,
186
+ automatic_zones=automatic_zones,
187
+ debug_level=get_int(r"^debug level:\s*(\d+)"),
188
+ query_logging=parse_bool_on_off(r"^query logging is\s*(\w+)"),
189
+ response_logging=parse_bool_on_off(r"^response logging is\s*(\w+)"),
190
+ memory_profiling_active=parse_bool_active(r"^memory profiling is\s*(\w+)"),
191
+ xfers_running=get_int(r"^xfers running:\s*(\d+)"),
192
+ xfers_deferred=get_int(r"^xfers deferred:\s*(\d+)"),
193
+ xfers_first_refresh=get_int(r"^xfers first refresh:\s*(\d+)"),
194
+ soa_queries_in_progress=get_int(r"^soa queries in progress:\s*(\d+)"),
195
+ recursive_clients_current=recursive_current,
196
+ recursive_clients_soft_limit=recursive_soft,
197
+ recursive_clients_hard_limit=recursive_hard,
198
+ recursive_high_water=get_int(r"^recursive high-water:\s*(\d+)"),
199
+ tcp_clients_current=tcp_current,
200
+ tcp_clients_limit=tcp_limit,
201
+ tcp_high_water=get_int(r"^TCP high-water:\s*(\d+)", 0),
202
+ server_is_up="server is up and running" in text.lower(),
203
+ raw_text=text,
204
+ )
@@ -17,6 +17,7 @@ import dns
17
17
  import dns.rdataclass
18
18
 
19
19
  from . import rndc_protocol
20
+ from .config import ENV_ALGORITHM, ENV_HOST, ENV_PORT, ENV_SECRET
20
21
  from .enums import TSIGAlgorithm
21
22
  from .exceptions import (
22
23
  RNDCAuthenticationError,
@@ -54,28 +55,28 @@ class RNDCClient:
54
55
  elif rndc_config is not None:
55
56
  self.host = rndc_config.host
56
57
  else:
57
- raise ValueError("host is required (provide it or set ZPAPI_RNDC_HOST)")
58
+ raise ValueError(f"host is required (provide it or set {ENV_HOST})")
58
59
 
59
60
  if port is not None:
60
61
  self.port = port
61
62
  elif rndc_config is not None:
62
63
  self.port = rndc_config.port
63
64
  else:
64
- raise ValueError("port is required (provide it or set ZPAPI_RNDC_PORT)")
65
+ raise ValueError(f"port is required (provide it or set {ENV_PORT})")
65
66
 
66
67
  if algorithm is not None:
67
68
  self.algorithm = algorithm
68
69
  elif rndc_config is not None:
69
70
  self.algorithm = rndc_config.algorithm
70
71
  else:
71
- raise ValueError("algorithm is required (provide it or set ZPAPI_RNDC_ALGORITHM)")
72
+ raise ValueError(f"algorithm is required (provide it or set {ENV_ALGORITHM})")
72
73
 
73
74
  if secret is not None:
74
75
  self.secret = base64.b64decode(secret)
75
76
  elif rndc_config is not None:
76
77
  self.secret = base64.b64decode(rndc_config.secret)
77
78
  else:
78
- raise ValueError("secret is required (provide it or set ZPAPI_RNDC_SECRET)")
79
+ raise ValueError(f"secret is required (provide it or set {ENV_SECRET})")
79
80
 
80
81
  if timeout is not None:
81
82
  self.timeout = timeout
@@ -198,7 +199,7 @@ class RNDCClient:
198
199
 
199
200
  try:
200
201
  remote_hash = base64.b64decode(b64_hash)
201
- except Exception:
202
+ except Exception: # noqa: BLE001 — malformed TSIG hash must fail verification, not raise
202
203
  return False
203
204
 
204
205
  # Verify hash
@@ -8,6 +8,13 @@ Assumes .env file contains all required defaults if they aren't set in the envir
8
8
  from typing import Any
9
9
 
10
10
  from .config import (
11
+ ENV_ALGORITHM,
12
+ ENV_HOST,
13
+ ENV_MAX_RETRIES,
14
+ ENV_PORT,
15
+ ENV_RETRY_DELAY,
16
+ ENV_SECRET,
17
+ ENV_TIMEOUT,
11
18
  _get_required_env_var,
12
19
  _load_env_file,
13
20
  _parse_float_env_var,
@@ -48,15 +55,13 @@ class RNDCConfig:
48
55
  retry_delay: float | None = None,
49
56
  ) -> None:
50
57
  _load_env_file()
51
- self.host = host or _get_required_env_var("ZPAPI_RNDC_HOST")
52
- self.port = port or _parse_port(_get_required_env_var("ZPAPI_RNDC_PORT"))
53
- self.algorithm = algorithm or _parse_algorithm(
54
- _get_required_env_var("ZPAPI_RNDC_ALGORITHM")
55
- )
56
- self.secret = secret or _get_required_env_var("ZPAPI_RNDC_SECRET")
57
- self.timeout = timeout or _parse_timeout(_get_required_env_var("ZPAPI_RNDC_TIMEOUT"))
58
- self.max_retries = max_retries or _parse_int_env_var("ZPAPI_RNDC_MAX_RETRIES", 3)
59
- self.retry_delay = retry_delay or _parse_float_env_var("ZPAPI_RNDC_RETRY_DELAY", 1.0)
58
+ self.host = host or _get_required_env_var(ENV_HOST)
59
+ self.port = port or _parse_port(_get_required_env_var(ENV_PORT))
60
+ self.algorithm = algorithm or _parse_algorithm(_get_required_env_var(ENV_ALGORITHM))
61
+ self.secret = secret or _get_required_env_var(ENV_SECRET)
62
+ self.timeout = timeout or _parse_timeout(_get_required_env_var(ENV_TIMEOUT))
63
+ self.max_retries = max_retries or _parse_int_env_var(ENV_MAX_RETRIES, 3)
64
+ self.retry_delay = retry_delay or _parse_float_env_var(ENV_RETRY_DELAY, 1.0)
60
65
 
61
66
  def to_dict(self) -> dict[str, Any]:
62
67
  return {
File without changes
File without changes