mreg-cli 1.2.0__tar.gz → 1.2.1__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.
Files changed (84) hide show
  1. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/CHANGELOG.md +11 -1
  2. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/PKG-INFO +1 -1
  3. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/ci/testsuite-result.json +4 -12
  4. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/mreg_cli/_version.py +2 -2
  5. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/mreg_cli/api/fields.py +26 -11
  6. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/mreg_cli/api/models.py +171 -63
  7. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/mreg_cli/cli.py +2 -2
  8. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/mreg_cli/commands/help.py +16 -6
  9. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/mreg_cli/commands/host_submodules/a_aaaa.py +3 -3
  10. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/mreg_cli/commands/host_submodules/rr.py +4 -4
  11. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/mreg_cli/commands/network.py +1 -1
  12. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/mreg_cli/commands/permission.py +2 -2
  13. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/mreg_cli/config.py +5 -0
  14. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/mreg_cli/errorbuilder.py +23 -23
  15. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/mreg_cli/exceptions.py +18 -12
  16. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/mreg_cli/main.py +2 -2
  17. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/mreg_cli/outputmanager.py +4 -5
  18. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/mreg_cli/utilities/api.py +2 -2
  19. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/mreg_cli.egg-info/PKG-INFO +1 -1
  20. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/tests/api/test_models.py +26 -2
  21. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/tests/test_errorbuilder.py +13 -2
  22. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/uv.lock +1 -1
  23. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/.github/workflows/publish.yml +0 -0
  24. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/.github/workflows/test.yml +0 -0
  25. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/.gitignore +0 -0
  26. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/.markdownlint.json +0 -0
  27. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/AUTHORS +0 -0
  28. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/CONTRIBUTING +0 -0
  29. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/LICENSE +0 -0
  30. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/README.md +0 -0
  31. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/ci/diff.py +0 -0
  32. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/ci/docker-compose.yml +0 -0
  33. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/ci/run_testsuite_and_record.sh +0 -0
  34. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/ci/testsuite +0 -0
  35. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/data/mreg-cli.conf +0 -0
  36. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/mreg_cli/__about__.py +0 -0
  37. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/mreg_cli/__init__.py +0 -0
  38. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/mreg_cli/__main__.py +0 -0
  39. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/mreg_cli/api/__init__.py +0 -0
  40. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/mreg_cli/api/abstracts.py +0 -0
  41. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/mreg_cli/api/endpoints.py +0 -0
  42. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/mreg_cli/api/history.py +0 -0
  43. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/mreg_cli/commands/__init__.py +0 -0
  44. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/mreg_cli/commands/base.py +0 -0
  45. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/mreg_cli/commands/dhcp.py +0 -0
  46. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/mreg_cli/commands/group.py +0 -0
  47. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/mreg_cli/commands/host.py +0 -0
  48. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/mreg_cli/commands/host_submodules/__init__.py +0 -0
  49. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/mreg_cli/commands/host_submodules/bacnet.py +0 -0
  50. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/mreg_cli/commands/host_submodules/cname.py +0 -0
  51. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/mreg_cli/commands/host_submodules/core.py +0 -0
  52. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/mreg_cli/commands/label.py +0 -0
  53. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/mreg_cli/commands/logging.py +0 -0
  54. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/mreg_cli/commands/policy.py +0 -0
  55. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/mreg_cli/commands/recording.py +0 -0
  56. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/mreg_cli/commands/registry.py +0 -0
  57. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/mreg_cli/commands/root.py +0 -0
  58. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/mreg_cli/commands/zone.py +0 -0
  59. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/mreg_cli/help_formatter.py +0 -0
  60. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/mreg_cli/prompt.py +0 -0
  61. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/mreg_cli/py.typed +0 -0
  62. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/mreg_cli/tags.txt +0 -0
  63. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/mreg_cli/tokenfile.py +0 -0
  64. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/mreg_cli/types.py +0 -0
  65. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/mreg_cli/utilities/__init__.py +0 -0
  66. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/mreg_cli/utilities/shared.py +0 -0
  67. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/mreg_cli/utilities/validators.py +0 -0
  68. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/mreg_cli.egg-info/SOURCES.txt +0 -0
  69. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/mreg_cli.egg-info/dependency_links.txt +0 -0
  70. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/mreg_cli.egg-info/entry_points.txt +0 -0
  71. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/mreg_cli.egg-info/requires.txt +0 -0
  72. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/mreg_cli.egg-info/top_level.txt +0 -0
  73. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/pyproject.toml +0 -0
  74. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/run.py +0 -0
  75. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/setup.cfg +0 -0
  76. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/tests/__init__.py +0 -0
  77. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/tests/api/__init__.py +0 -0
  78. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/tests/api/test_fields.py +0 -0
  79. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/tests/conftest.py +0 -0
  80. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/tests/test_prompt.py +0 -0
  81. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/tests/test_tokenfile.py +0 -0
  82. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/tests/utilities/__init__.py +0 -0
  83. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/tests/utilities/test_api.py +0 -0
  84. {mreg_cli-1.2.0 → mreg_cli-1.2.1}/tox.ini +0 -0
@@ -5,7 +5,17 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- <!-- ## [Unreleased] -->
8
+ <!-- ## Unreleased -->
9
+
10
+ ## [1.2.1](https://github.com/unioslo/mreg-cli/releases/tag/1.2.1) - 2024-12-02
11
+
12
+ ### Fixed
13
+
14
+ - Host lookup with IPv6 addresses raising `ValidationError` when no host can be found with the given address.
15
+
16
+ ### Removed
17
+
18
+ - Support for fetching hosts that have a valid IP/MAC address as their hostname.
9
19
 
10
20
  ## [1.2.0](https://github.com/unioslo/mreg-cli/releases/tag/1.2.0) - 2024-11-25
11
21
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: mreg-cli
3
- Version: 1.2.0
3
+ Version: 1.2.1
4
4
  Summary: MREG interactive command line client
5
5
  Author: Others (see AUTHORS)
6
6
  Author-email: Øyvind Kolbu <oyvind.kolbu@usit.uio.no>, Safet A <amedov@usit.uio.no>, Magnus Hirth <mhmorgan42@gmail.com>, Nico <nicolaydmohebi@gmail.com>, Marius Bakke <marius.bakke@usit.uio.no>, Fredrik Larsen <fredrik.larsen@usit.uio.no>, Paal Braathen <paal.braathen@usit.uio.no>, Nils Hiorth <nils@nls.me>, Dmytro Karpenko <dmytrok@usit.uio.no>
@@ -6,7 +6,6 @@
6
6
  "command_issued": "zone create example.org hostmaster@example.org ns1.example.org # should require force because ns1 is unknown",
7
7
  "ok": [],
8
8
  "warning": [
9
- "Host ns1.example.org not found.",
10
9
  "ns1.example.org is not in mreg, must force"
11
10
  ],
12
11
  "error": [],
@@ -41,9 +40,7 @@
41
40
  "ok": [
42
41
  "Created zone example.org"
43
42
  ],
44
- "warning": [
45
- "Host ns1.example.org not found."
46
- ],
43
+ "warning": [],
47
44
  "error": [],
48
45
  "output": [],
49
46
  "api_requests": [
@@ -210,10 +207,9 @@
210
207
  "command": "zone set_ns example.org ns2.example.org",
211
208
  "command_filter": null,
212
209
  "command_filter_negate": false,
213
- "command_issued": "zone set_ns example.org ns2.example.org # requires force because ns2 is unknown",
210
+ "command_issued": "zone set_ns example.org ns2.example.org # requires force because ns2 is unknown",
214
211
  "ok": [],
215
212
  "warning": [
216
- "Host ns2.example.org not found.",
217
213
  "ns2.example.org is not in mreg, must force"
218
214
  ],
219
215
  "error": [],
@@ -271,9 +267,7 @@
271
267
  "ok": [
272
268
  "Updated nameservers for example.org"
273
269
  ],
274
- "warning": [
275
- "Host ns2.example.org not found."
276
- ],
270
+ "warning": [],
277
271
  "error": [],
278
272
  "output": [],
279
273
  "api_requests": [
@@ -15283,9 +15277,7 @@
15283
15277
  "ok": [
15284
15278
  "Created zone delegation wut.example.org"
15285
15279
  ],
15286
- "warning": [
15287
- "Host ns2.example.org not found."
15288
- ],
15280
+ "warning": [],
15289
15281
  "error": [],
15290
15282
  "output": [],
15291
15283
  "api_requests": [
@@ -12,5 +12,5 @@ __version__: str
12
12
  __version_tuple__: VERSION_TUPLE
13
13
  version_tuple: VERSION_TUPLE
14
14
 
15
- __version__ = version = '1.2.0'
16
- __version_tuple__ = version_tuple = (1, 2, 0)
15
+ __version__ = version = '1.2.1'
16
+ __version_tuple__ = version_tuple = (1, 2, 1)
@@ -21,25 +21,40 @@ class MACAddressField(FrozenModel):
21
21
 
22
22
  address: MacAddress
23
23
 
24
- @classmethod
25
- def validate(cls, value: str | MacAddress | Self) -> Self:
26
- """Validate a MAC address and return it as a string."""
27
- try:
28
- return cls.validate_naive(value)
29
- except ValueError as e:
30
- raise InputFailure(e) from e
31
-
32
24
  # HACK: extremely hacky workaround for our custom exceptions always
33
25
  # logging errors/warnings even when caught.
34
26
  @classmethod
35
- def validate_naive(cls, value: str | MacAddress | Self) -> Self:
27
+ def validate(cls, value: str | MacAddress | Self) -> Self:
36
28
  """Validate but raise built-in exceptions on failure."""
37
29
  if isinstance(value, MACAddressField):
38
- return cls.validate_naive(value.address)
30
+ return cls.validate(value.address)
39
31
  try:
40
32
  return cls(address=value) # pyright: ignore[reportArgumentType]
41
33
  except ValidationError as e:
42
- raise ValueError(f"Invalid MAC address '{value}'") from e
34
+ raise InputFailure(f"Invalid MAC address '{value}'") from e
35
+
36
+ @classmethod
37
+ def parse_or_raise(cls, obj: Any) -> MacAddress:
38
+ """Parse a MAC address from a string. Returns the MAC address as a string.
39
+
40
+ :param obj: The object to parse.
41
+ :returns: The MAC address as a string.
42
+ :raises ValueError: If the object is not a valid MAC address.
43
+ """
44
+ # Match interface of NetworkOrIP.parse_or_raise
45
+ return cls.validate(obj).address
46
+
47
+ @classmethod
48
+ def parse(cls, obj: Any) -> MacAddress | None:
49
+ """Parse a MAC address from a string. Returns None if the MAC address is invalid.
50
+
51
+ :param obj: The object to parse.
52
+ :returns: The MAC address as a string or None if it is invalid.
53
+ """
54
+ try:
55
+ return cls.parse_or_raise(obj)
56
+ except ValueError:
57
+ return None
43
58
 
44
59
  def __str__(self) -> str:
45
60
  """Return the MAC address as a string."""
@@ -7,9 +7,11 @@ import logging
7
7
  import re
8
8
  from datetime import date, datetime
9
9
  from functools import cached_property
10
- from typing import Any, Callable, ClassVar, List, Literal, Self, cast, overload
10
+ from typing import Any, Callable, ClassVar, Iterable, List, Literal, Self, cast, overload
11
11
 
12
12
  from pydantic import AliasChoices, BaseModel, ConfigDict, Field, computed_field, field_validator
13
+ from pydantic import ValidationError as PydanticValidationError
14
+ from pydantic_extra_types.mac_address import MacAddress
13
15
  from typing_extensions import Unpack
14
16
 
15
17
  from mreg_cli.api.abstracts import APIMixin, FrozenModel, FrozenModelWithTimestamps
@@ -75,35 +77,41 @@ class NetworkOrIP(BaseModel):
75
77
  return cls.validate(value.ip_or_network)
76
78
  try:
77
79
  return cls(ip_or_network=value) # pyright: ignore[reportArgumentType] # validator handles this
78
- except ValidationError as e:
80
+ except PydanticValidationError as e:
79
81
  raise InputFailure(f"Invalid IP address or network: {value}") from e
80
82
 
81
83
  @overload
82
84
  @classmethod
83
- def parse(cls, value: Any, mode: None = None) -> IP_AddressT | IP_NetworkT: ...
85
+ def parse_or_raise(cls, value: Any, mode: None = None) -> IP_AddressT | IP_NetworkT: ...
84
86
 
85
87
  @overload
86
88
  @classmethod
87
- def parse(cls, value: Any, mode: Literal["ip"]) -> IP_AddressT: ...
89
+ def parse_or_raise(cls, value: Any, mode: Literal["ip"]) -> IP_AddressT: ...
88
90
 
89
91
  @overload
90
92
  @classmethod
91
- def parse(cls, value: Any, mode: Literal["ipv4"]) -> ipaddress.IPv4Address: ...
93
+ def parse_or_raise(cls, value: Any, mode: Literal["ipv4"]) -> ipaddress.IPv4Address: ...
92
94
 
93
95
  @overload
94
96
  @classmethod
95
- def parse(cls, value: Any, mode: Literal["ipv6"]) -> ipaddress.IPv6Address: ...
97
+ def parse_or_raise(cls, value: Any, mode: Literal["ipv6"]) -> ipaddress.IPv6Address: ...
96
98
 
97
99
  @overload
98
100
  @classmethod
99
- def parse(cls, value: Any, mode: Literal["network"]) -> IP_NetworkT: ...
101
+ def parse_or_raise(cls, value: Any, mode: Literal["network"]) -> IP_NetworkT: ...
100
102
 
101
103
  @overload
102
104
  @classmethod
103
- def parse(cls, value: Any, mode: IPNetMode) -> IP_AddressT | IP_NetworkT: ...
105
+ def parse_or_raise(cls, value: Any, mode: Literal["networkv4"]) -> ipaddress.IPv4Network: ...
106
+
107
+ @overload
108
+ @classmethod
109
+ def parse_or_raise(cls, value: Any, mode: Literal["networkv6"]) -> ipaddress.IPv6Network: ...
104
110
 
105
111
  @classmethod
106
- def parse(cls, value: Any, mode: IPNetMode | None = None) -> IP_AddressT | IP_NetworkT:
112
+ def parse_or_raise(
113
+ cls, value: Any, mode: IPNetMode | None = None
114
+ ) -> IP_AddressT | IP_NetworkT:
107
115
  """Parse a value as an IP address or network.
108
116
 
109
117
  Optionally specify the mode to validate the input as.
@@ -126,6 +134,49 @@ class NetworkOrIP(BaseModel):
126
134
  return func(ipnet)
127
135
  return ipnet.ip_or_network
128
136
 
137
+ @overload
138
+ @classmethod
139
+ def parse(cls, value: Any, mode: None = None) -> IP_AddressT | IP_NetworkT | None: ...
140
+
141
+ @overload
142
+ @classmethod
143
+ def parse(cls, value: Any, mode: Literal["ip"]) -> IP_AddressT | None: ...
144
+
145
+ @overload
146
+ @classmethod
147
+ def parse(cls, value: Any, mode: Literal["ipv4"]) -> ipaddress.IPv4Address | None: ...
148
+
149
+ @overload
150
+ @classmethod
151
+ def parse(cls, value: Any, mode: Literal["ipv6"]) -> ipaddress.IPv6Address | None: ...
152
+
153
+ @overload
154
+ @classmethod
155
+ def parse(cls, value: Any, mode: Literal["network"]) -> IP_NetworkT | None: ...
156
+
157
+ @overload
158
+ @classmethod
159
+ def parse(cls, value: Any, mode: Literal["networkv4"]) -> ipaddress.IPv4Network | None: ...
160
+
161
+ @overload
162
+ @classmethod
163
+ def parse(cls, value: Any, mode: Literal["networkv6"]) -> ipaddress.IPv6Network | None: ...
164
+
165
+ @classmethod
166
+ def parse(cls, value: Any, mode: IPNetMode | None = None) -> IP_AddressT | IP_NetworkT | None:
167
+ """Parse a value as an IP address or network, or None if parsing fails.
168
+
169
+ Optionally specify the mode to validate the input as.
170
+
171
+ :param value:The value to parse.
172
+ :param mode: The mode to validate the input as.
173
+ :returns: The parsed value as an IP address or network, or None.
174
+ """
175
+ try:
176
+ return cls.parse_or_raise(value, mode)
177
+ except ValueError:
178
+ return None
179
+
129
180
  @field_validator("ip_or_network", mode="before")
130
181
  @classmethod
131
182
  def validate_ip_or_network(cls, value: Any) -> IP_AddressT | IP_NetworkT:
@@ -1580,7 +1631,7 @@ class Network(FrozenModelWithTimestamps, APIMixin):
1580
1631
  def ip_network(self) -> IP_NetworkT:
1581
1632
  """IP network object for the network."""
1582
1633
  try:
1583
- return NetworkOrIP.parse(self.network, mode="network")
1634
+ return NetworkOrIP.parse_or_raise(self.network, mode="network")
1584
1635
  except IPNetworkWarning as e:
1585
1636
  logger.error(
1586
1637
  "Invalid network address %s for network with ID %s", self.network, self.id
@@ -1707,7 +1758,7 @@ class Network(FrozenModelWithTimestamps, APIMixin):
1707
1758
  def fmt(label: str, value: Any) -> None:
1708
1759
  manager.add_line(f"{label:<{padding}}{value}")
1709
1760
 
1710
- ipnet = NetworkOrIP.parse(self.network, mode="network")
1761
+ ipnet = NetworkOrIP.parse_or_raise(self.network, mode="network")
1711
1762
  reserved_ips = self.get_reserved_ips()
1712
1763
  # Remove network address and broadcast address from reserved IPs
1713
1764
  reserved_ips_filtered = [
@@ -1799,9 +1850,9 @@ class Network(FrozenModelWithTimestamps, APIMixin):
1799
1850
  if isinstance(other, Network):
1800
1851
  other = other.network
1801
1852
  if isinstance(other, str):
1802
- other = NetworkOrIP.parse(other, mode="network")
1853
+ other = NetworkOrIP.parse_or_raise(other, mode="network")
1803
1854
 
1804
- self_net = NetworkOrIP.parse(self.network, mode="network")
1855
+ self_net = NetworkOrIP.parse_or_raise(self.network, mode="network")
1805
1856
  return self_net.overlaps(other)
1806
1857
 
1807
1858
  def get_first_available_ip(self) -> IP_AddressT:
@@ -2610,6 +2661,58 @@ class Host(FrozenModelWithTimestamps, WithTTL, WithHistory, APIMixin):
2610
2661
  """Return the endpoint for the class."""
2611
2662
  return Endpoint.Hosts
2612
2663
 
2664
+ @classmethod
2665
+ def get_by_ip(cls, ip: IP_AddressT, inform_as_ptr: bool = True) -> Host | None:
2666
+ """Get a host by IP address.
2667
+
2668
+ :param ip: The IP address to search for.
2669
+ :param check_ptr: If True, check for PTR overrides as well.
2670
+ :returns: The Host object if found, None otherwise.
2671
+ """
2672
+ try:
2673
+ host = cls.get_by_field("ipaddresses__ipaddress", str(ip))
2674
+ if not host:
2675
+ host = cls.get_by_field("ptr_overrides__ipaddress", str(ip))
2676
+ if host and inform_as_ptr:
2677
+ OutputManager().add_line(f"{ip} is a PTR override for {host.name}")
2678
+ return host
2679
+ except MultipleEntitiesFound as e:
2680
+ raise MultipleEntitiesFound(f"Multiple hosts found with IP address {ip}.") from e
2681
+
2682
+ @classmethod
2683
+ def get_by_ip_or_raise(cls, ip: IP_AddressT, inform_as_ptr: bool = True) -> Host:
2684
+ """Get a host by IP address or raise EntityNotFound.
2685
+
2686
+ :param ip: The IP address to search for.
2687
+ :returns: The Host object if found.
2688
+ :param check_ptr: If True, check for PTR overrides as well.
2689
+ """
2690
+ host = cls.get_by_ip(ip, inform_as_ptr=inform_as_ptr)
2691
+ if not host:
2692
+ raise EntityNotFound(f"Host with IP address {ip} not found.")
2693
+ return host
2694
+
2695
+ @classmethod
2696
+ def get_by_mac(cls, mac: MacAddress) -> Host | None:
2697
+ """Get a host by MAC address.
2698
+
2699
+ :param ip: The MAC address to search for.
2700
+ :returns: The Host object if found, None otherwise.
2701
+ """
2702
+ return cls.get_by_field("ipaddresses__macaddress", str(mac))
2703
+
2704
+ @classmethod
2705
+ def get_by_mac_or_raise(cls, mac: MacAddress) -> Host:
2706
+ """Get a host by MAC address or raise EntityNotFound.
2707
+
2708
+ :param ip: The MAC address to search for.
2709
+ :returns: The Host object if found.
2710
+ """
2711
+ host = cls.get_by_mac(mac)
2712
+ if not host:
2713
+ raise EntityNotFound(f"Host with MAC address {mac} not found.")
2714
+ return host
2715
+
2613
2716
  @classmethod
2614
2717
  def get_by_any_means_or_raise(
2615
2718
  cls, identifier: str | HostT, inform_as_cname: bool = True, inform_as_ptr: bool = True
@@ -2670,56 +2773,31 @@ class Host(FrozenModelWithTimestamps, WithTTL, WithHistory, APIMixin):
2670
2773
 
2671
2774
  :returns: A Host object if the host was found, otherwise None.
2672
2775
  """
2673
- host = None
2674
2776
  if not isinstance(identifier, HostT):
2675
2777
  if identifier.isdigit():
2676
2778
  return Host.get_by_id(int(identifier))
2677
2779
 
2678
- try:
2679
- ptr = False
2680
- ipaddress.ip_address(identifier)
2681
- NetworkOrIP.parse(identifier, mode="ip")
2682
-
2683
- host = Host.get_by_field("ipaddresses__ipaddress", identifier)
2684
- if not host:
2685
- host = Host.get_by_field("ptr_overrides__ipaddress", identifier)
2686
- ptr = True
2687
-
2688
- if host:
2689
- if ptr and inform_as_ptr:
2690
- OutputManager().add_line(f"{identifier} is a PTR override for {host.name}")
2691
- return host
2692
- except MultipleEntitiesFound as e:
2693
- raise MultipleEntitiesFound(
2694
- f"Multiple hosts found with IP address or PTR {identifier}."
2695
- ) from e
2696
- except ValueError: # invalid IP
2697
- pass
2780
+ if ip := NetworkOrIP.parse(identifier, mode="ip"):
2781
+ host = cls.get_by_ip_or_raise(ip, inform_as_ptr=inform_as_ptr)
2782
+ return host
2698
2783
 
2699
- try:
2700
- mac = MACAddressField.validate_naive(identifier)
2701
- return Host.get_by_field("ipaddresses__macaddress", mac.address)
2702
- except ValueError:
2703
- pass
2784
+ if mac := MACAddressField.parse(identifier):
2785
+ return cls.get_by_mac_or_raise(mac)
2704
2786
 
2705
2787
  # Let us try to find the host by name...
2706
- name = HostT(hostname=identifier)
2707
- else:
2708
- name = identifier
2709
-
2710
- host = Host.get_by_field("name", name.hostname)
2788
+ identifier = HostT(hostname=identifier)
2711
2789
 
2712
- if host:
2790
+ if host := cls.get_by_field("name", identifier.hostname):
2713
2791
  return host
2714
2792
 
2715
- cname = CNAME.get_by_field("name", name.hostname)
2793
+ cname = CNAME.get_by_field("name", identifier.hostname)
2716
2794
  # If we found a CNAME, get the host it points to. We're not interested in the
2717
2795
  # CNAME itself.
2718
2796
  if cname is not None:
2719
2797
  host = Host.get_by_id(cname.host)
2720
2798
 
2721
2799
  if host and inform_as_cname:
2722
- OutputManager().add_line(f"{name} is a CNAME for {host.name}")
2800
+ OutputManager().add_line(f"{identifier.hostname} is a CNAME for {host.name}")
2723
2801
 
2724
2802
  return host
2725
2803
 
@@ -2797,7 +2875,7 @@ class Host(FrozenModelWithTimestamps, WithTTL, WithHistory, APIMixin):
2797
2875
  :returns: The IP address object if found, None otherwise.
2798
2876
  """
2799
2877
  if not isinstance(arg_mac, MACAddressField):
2800
- arg_mac = MACAddressField.validate_naive(arg_mac)
2878
+ arg_mac = MACAddressField.validate(arg_mac)
2801
2879
  return next((ip for ip in self.ipaddresses if ip.macaddress == arg_mac), None)
2802
2880
 
2803
2881
  def ips_with_macaddresses(self) -> list[IPAddress]:
@@ -3522,6 +3600,39 @@ class UserPermission(BaseModel):
3522
3600
  regex: str
3523
3601
  labels: List[str]
3524
3602
 
3603
+ # NOTE: _needs_ to be a computed field in order to use it in
3604
+ # OutputManager.add_formatted_table, since we dump the model to a dict
3605
+ # inside that method.
3606
+ @computed_field
3607
+ @property
3608
+ def labels_str(self) -> str:
3609
+ """Return the labels as a string."""
3610
+ return ", ".join(self.labels)
3611
+
3612
+ @classmethod
3613
+ def output_multiple(cls, permissions: Iterable[Self]) -> None:
3614
+ """Output multiple permissions to the console.
3615
+
3616
+ :param permissions: List of UserPermission records to output.
3617
+ """
3618
+ # NOTE: this is more or less identical to `Permission.output_multiple()`
3619
+ # with the addition of printing labels.
3620
+ # Since UserPermission is a different model from Permission, sharing a
3621
+ # common method is difficult to make type safe without declaring some sort
3622
+ # of protocol class, which seems a bit overkill.
3623
+ manager = OutputManager()
3624
+ manager.add_line("Permissions:")
3625
+ if not permissions:
3626
+ manager.add_line(" None")
3627
+ return
3628
+
3629
+ OutputManager().add_formatted_table(
3630
+ ("IP range", "Group", "Reg.exp.", "Labels"),
3631
+ ("range", "group", "regex", "labels_str"),
3632
+ permissions,
3633
+ indent=2,
3634
+ )
3635
+
3525
3636
 
3526
3637
  class ServerVersion(BaseModel):
3527
3638
  """Model for server version metadata."""
@@ -3657,15 +3768,18 @@ class UserInfo(BaseModel):
3657
3768
  )
3658
3769
  raise e
3659
3770
 
3660
- def output(self) -> None:
3771
+ def output(self, django: bool = False) -> None:
3661
3772
  """Output the user information to the console."""
3662
3773
  outputmanager = OutputManager()
3663
3774
  outputmanager.add_line(f"Username: {self.username}")
3664
- outputmanager.add_line("Django status:")
3665
- outputmanager.add_line(f" Superuser: {self.django_status.superuser}")
3666
- outputmanager.add_line(f" Staff: {self.django_status.staff}")
3667
- outputmanager.add_line(f" Active: {self.django_status.active}")
3668
- outputmanager.add_line("Mreg status:")
3775
+
3776
+ if django:
3777
+ outputmanager.add_line("Django roles:")
3778
+ outputmanager.add_line(f" Superuser: {self.django_status.superuser}")
3779
+ outputmanager.add_line(f" Staff: {self.django_status.staff}")
3780
+ outputmanager.add_line(f" Active: {self.django_status.active}")
3781
+
3782
+ outputmanager.add_line("Mreg roles:")
3669
3783
  outputmanager.add_line(f" Superuser: {self.mreg_status.superuser}")
3670
3784
  outputmanager.add_line(f" Admin: {self.mreg_status.admin}")
3671
3785
  outputmanager.add_line(f" Group admin: {self.mreg_status.group_admin}")
@@ -3673,15 +3787,9 @@ class UserInfo(BaseModel):
3673
3787
  outputmanager.add_line(f" Hostpolicy admin: {self.mreg_status.hostpolicy_admin}")
3674
3788
  outputmanager.add_line(f" DNS wildcard admin: {self.mreg_status.dns_wildcard_admin}")
3675
3789
  outputmanager.add_line(f" Underscore admin: {self.mreg_status.underscore_admin}")
3790
+
3676
3791
  outputmanager.add_line("Groups:")
3677
3792
  for group in self.groups:
3678
3793
  outputmanager.add_line(f" {group}")
3679
- outputmanager.add_line("Permissions:")
3680
- if not self.permissions:
3681
- outputmanager.add_line(" None")
3682
-
3683
- for perm in self.permissions:
3684
- outputmanager.add_line(f" Group: {perm.group}")
3685
- outputmanager.add_line(f" Range: {perm.range}")
3686
- outputmanager.add_line(f" Regex: {perm.regex}")
3687
- outputmanager.add_line(f" Labels: {', '.join(perm.labels)}")
3794
+
3795
+ UserPermission.output_multiple(self.permissions)
@@ -167,7 +167,7 @@ class Command(Completer):
167
167
  self.last_errno = e.code
168
168
 
169
169
  except (CliWarning, CliError) as exc:
170
- exc.print_self()
170
+ exc.print_and_log()
171
171
 
172
172
  except CliExit:
173
173
  # If we have active recordings going on, save them before exiting
@@ -263,7 +263,7 @@ class Command(Completer):
263
263
  try:
264
264
  cmd = output.from_command(line)
265
265
  except (CliWarning, CliError) as exc:
266
- exc.print_self()
266
+ exc.print_and_log()
267
267
  return
268
268
  # Create and set the corrolation id, using the cleaned command
269
269
  # as the suffix. This is used to track the command in the logs
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import argparse
6
+ import logging
6
7
  from typing import Any
7
8
 
8
9
  from mreg_cli.__about__ import __version__ as mreg_cli_version
@@ -10,6 +11,7 @@ from mreg_cli.api.models import ServerLibraries, ServerVersion, UserInfo
10
11
  from mreg_cli.commands.base import BaseCommand
11
12
  from mreg_cli.commands.registry import CommandRegistry
12
13
  from mreg_cli.config import MregCliConfig
14
+ from mreg_cli.exceptions import CliError
13
15
  from mreg_cli.outputmanager import OutputManager
14
16
  from mreg_cli.types import Flag
15
17
 
@@ -103,24 +105,32 @@ def versions_help(_: argparse.Namespace) -> None:
103
105
  prog="whoami",
104
106
  description="Show information about the the current user",
105
107
  short_desc="Show self info",
108
+ flags=[
109
+ Flag("-django", action="store_true", description="Show Django internal roles"),
110
+ ],
106
111
  )
107
- def whoami_help(_: argparse.Namespace) -> None:
112
+ def whoami_help(args: argparse.Namespace) -> None:
108
113
  """Show information about the current user."""
109
114
  try:
110
- UserInfo.fetch(ignore_errors=False).output()
115
+ UserInfo.fetch(ignore_errors=False).output(django=args.django)
111
116
  except Exception as e:
112
- print("Failed to fetch user info:", e)
117
+ raise CliError(
118
+ f"Failed to display user info for current user {MregCliConfig().get_user()!r}: {e}"
119
+ ) from e
113
120
 
114
121
 
115
122
  @command_registry.register_command(
116
123
  prog="whois",
117
124
  description="Show information about a user",
118
125
  short_desc="Show user info",
119
- flags=[Flag("user", description="The user to show information about")],
126
+ flags=[
127
+ Flag("user", description="The user to show information about"),
128
+ Flag("-django", action="store_true", description="Show Django internal roles"),
129
+ ],
120
130
  )
121
131
  def whois_help(args: argparse.Namespace) -> None:
122
132
  """Show information about a user."""
123
133
  try:
124
- UserInfo.fetch(ignore_errors=False, user=args.user).output()
134
+ UserInfo.fetch(ignore_errors=False, user=args.user).output(django=args.django)
125
135
  except Exception as e:
126
- print("Failed to fetch user info:", e)
136
+ raise CliError(f"Failed to display user info for {args.user!r}: {e}") from e
@@ -49,7 +49,7 @@ def _ip_change(args: argparse.Namespace, ipversion: IP_Version) -> None:
49
49
  if args.old == args.new:
50
50
  raise EntityAlreadyExists("New and old IP are equal")
51
51
 
52
- old_ip = NetworkOrIP.parse(args.old, mode="ip")
52
+ old_ip = NetworkOrIP.parse_or_raise(args.old, mode="ip")
53
53
 
54
54
  new_ip = NetworkOrIP.validate(args.new)
55
55
  if new_ip.is_network():
@@ -88,7 +88,7 @@ def _ip_move(args: argparse.Namespace, ipversion: IP_Version) -> None:
88
88
  :param args: argparse.Namespace (ip, fromhost, tohost)
89
89
  :param ipversion: 4 or 6
90
90
  """
91
- ip = NetworkOrIP.parse(args.ip, mode="ip")
91
+ ip = NetworkOrIP.parse_or_raise(args.ip, mode="ip")
92
92
  if ip.version != ipversion:
93
93
  raise InputFailure(
94
94
  f"IP version {ip.version} does not match the requested version {ipversion}"
@@ -123,7 +123,7 @@ def _ip_remove(args: argparse.Namespace, ipversion: IP_Version) -> None:
123
123
  :param args: argparse.Namespace (name, ip)
124
124
  """
125
125
  host = Host.get_by_any_means_or_raise(args.name)
126
- ip = NetworkOrIP.parse(args.ip, mode="ip")
126
+ ip = NetworkOrIP.parse_or_raise(args.ip, mode="ip")
127
127
  if ip.version != ipversion:
128
128
  raise InputFailure(
129
129
  f"IP version {ip.version} does not match the requested version {ipversion}"
@@ -474,7 +474,7 @@ def ptr_change(args: argparse.Namespace) -> None:
474
474
  if not old_host.ptr_overrides:
475
475
  raise EntityNotFound(f"No PTR records for {old_host}")
476
476
 
477
- ip = NetworkOrIP.parse(args.ip, mode="ip")
477
+ ip = NetworkOrIP.parse_or_raise(args.ip, mode="ip")
478
478
  ptr_override = old_host.get_ptr_override(ip)
479
479
  if not ptr_override:
480
480
  raise EntityNotFound(f"No PTR record for {old_host} with IP {ip}")
@@ -503,7 +503,7 @@ def ptr_remove(args: argparse.Namespace) -> None:
503
503
  :param args: argparse.Namespace (ip, name)
504
504
  """
505
505
  host = Host.get_by_any_means_or_raise(args.name)
506
- ip = NetworkOrIP.parse(args.ip, mode="ip")
506
+ ip = NetworkOrIP.parse_or_raise(args.ip, mode="ip")
507
507
  ptr_override = host.get_ptr_override(ip)
508
508
  if not ptr_override:
509
509
  raise EntityNotFound(f"No PTR record for {host} with IP {ip}")
@@ -529,7 +529,7 @@ def ptr_add(args: argparse.Namespace) -> None:
529
529
 
530
530
  :param args: argparse.Namespace (ip, name, force)
531
531
  """
532
- ip = NetworkOrIP.parse(args.ip, mode="ip")
532
+ ip = NetworkOrIP.parse_or_raise(args.ip, mode="ip")
533
533
 
534
534
  host = Host.get_by_any_means_or_raise(args.name)
535
535
  existing_ptrs = PTR_override.get_list_by_field("ipaddress", str(ip))
@@ -563,7 +563,7 @@ def ptr_show(args: argparse.Namespace) -> None:
563
563
 
564
564
  :param args: argparse.Namespace (ip)
565
565
  """
566
- ip = NetworkOrIP.parse(args.ip, mode="ip")
566
+ ip = NetworkOrIP.parse_or_raise(args.ip, mode="ip")
567
567
  host = Host.get_by_any_means_or_raise(str(ip), inform_as_ptr=False)
568
568
  if not host.ptr_overrides:
569
569
  OutputManager().add_line(f"No PTR records for {host.name.hostname}")
@@ -67,7 +67,7 @@ def create(args: argparse.Namespace) -> None:
67
67
  if args.location and not is_valid_location_tag(args.location):
68
68
  raise InputFailure("Not a valid location tag")
69
69
 
70
- arg_network = NetworkOrIP.parse(args.network, mode="network")
70
+ arg_network = NetworkOrIP.parse_or_raise(args.network, mode="network")
71
71
  networks = Network.get_list()
72
72
  for network in networks:
73
73
  if network.overlaps(arg_network):
@@ -56,7 +56,7 @@ def network_list(args: argparse.Namespace) -> None:
56
56
  permissions = Permission.get_by_query(query=params, ordering="range,group", limit=None)
57
57
 
58
58
  if args.range is not None:
59
- argnetwork = NetworkOrIP.parse(args.range, mode="network")
59
+ argnetwork = NetworkOrIP.parse_or_raise(args.range, mode="network")
60
60
 
61
61
  for permission in permissions:
62
62
  permnet = permission.range
@@ -107,7 +107,7 @@ def network_add(args: argparse.Namespace) -> None:
107
107
 
108
108
  :param args: argparse.Namespace (range, group, regex)
109
109
  """
110
- NetworkOrIP.parse(args.range, mode="network")
110
+ NetworkOrIP.parse_or_raise(args.range, mode="network")
111
111
 
112
112
  query = {
113
113
  "range": args.range,