mreg-cli 1.6.0__tar.gz → 1.7.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.
Files changed (99) hide show
  1. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/CHANGELOG.md +55 -0
  2. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/PKG-INFO +2 -1
  3. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/mreg_cli/_version.py +3 -3
  4. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/mreg_cli/api/fields.py +3 -3
  5. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/mreg_cli/api/models.py +24 -11
  6. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/mreg_cli/cache.py +24 -9
  7. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/mreg_cli/cli.py +6 -4
  8. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/mreg_cli/commands/cache.py +5 -5
  9. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/mreg_cli/commands/help.py +6 -1
  10. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/mreg_cli/commands/host_submodules/core.py +2 -2
  11. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/mreg_cli/commands/logging.py +49 -18
  12. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/mreg_cli/commands/policy.py +23 -5
  13. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/mreg_cli/commands/recording.py +3 -2
  14. mreg_cli-1.7.0/mreg_cli/config.py +363 -0
  15. mreg_cli-1.7.0/mreg_cli/dirs.py +41 -0
  16. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/mreg_cli/exceptions.py +33 -18
  17. mreg_cli-1.7.0/mreg_cli/log.py +74 -0
  18. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/mreg_cli/main.py +55 -27
  19. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/mreg_cli/outputmanager.py +16 -17
  20. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/mreg_cli/prompt.py +8 -18
  21. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/mreg_cli/types.py +3 -1
  22. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/mreg_cli/utilities/api.py +13 -11
  23. mreg_cli-1.7.0/mreg_cli/utilities/fs.py +57 -0
  24. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/mreg_cli/utilities/validators.py +3 -3
  25. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/mreg_cli.egg-info/PKG-INFO +2 -1
  26. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/mreg_cli.egg-info/SOURCES.txt +6 -1
  27. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/mreg_cli.egg-info/requires.txt +1 -0
  28. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/pyproject.toml +6 -1
  29. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/run.py +2 -0
  30. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/tests/api/test_fields.py +2 -1
  31. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/tests/conftest.py +31 -20
  32. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/tests/test_cache.py +23 -16
  33. mreg_cli-1.7.0/tests/test_config.py +237 -0
  34. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/tests/test_exceptions.py +5 -6
  35. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/tests/test_outputmanager.py +1 -1
  36. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/tests/test_prompt.py +10 -12
  37. mreg_cli-1.7.0/tests/utilities/test_fs.py +140 -0
  38. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/uv.lock +37 -0
  39. mreg_cli-1.6.0/mreg_cli/config.py +0 -336
  40. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/.github/workflows/publish.yml +0 -0
  41. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/.github/workflows/test.yml +0 -0
  42. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/.gitignore +0 -0
  43. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/.markdownlint.json +0 -0
  44. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/AUTHORS +0 -0
  45. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/CONTRIBUTING +0 -0
  46. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/LICENSE +0 -0
  47. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/README.md +0 -0
  48. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/ci/Dockerfile +0 -0
  49. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/ci/diff.py +0 -0
  50. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/ci/docker-compose.yml +0 -0
  51. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/ci/run_testsuite_and_record.sh +0 -0
  52. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/ci/run_testsuite_and_record_V2.sh +0 -0
  53. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/ci/testsuite +0 -0
  54. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/ci/testsuite-result.json +0 -0
  55. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/data/mreg-cli.conf +0 -0
  56. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/mreg_cli/__about__.py +0 -0
  57. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/mreg_cli/__init__.py +0 -0
  58. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/mreg_cli/__main__.py +0 -0
  59. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/mreg_cli/api/__init__.py +0 -0
  60. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/mreg_cli/api/abstracts.py +0 -0
  61. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/mreg_cli/api/endpoints.py +0 -0
  62. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/mreg_cli/api/errors.py +0 -0
  63. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/mreg_cli/api/history.py +0 -0
  64. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/mreg_cli/commands/__init__.py +0 -0
  65. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/mreg_cli/commands/base.py +0 -0
  66. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/mreg_cli/commands/dhcp.py +0 -0
  67. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/mreg_cli/commands/group.py +0 -0
  68. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/mreg_cli/commands/host.py +0 -0
  69. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/mreg_cli/commands/host_submodules/__init__.py +0 -0
  70. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/mreg_cli/commands/host_submodules/a_aaaa.py +0 -0
  71. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/mreg_cli/commands/host_submodules/bacnet.py +0 -0
  72. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/mreg_cli/commands/host_submodules/cname.py +0 -0
  73. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/mreg_cli/commands/host_submodules/rr.py +0 -0
  74. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/mreg_cli/commands/label.py +0 -0
  75. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/mreg_cli/commands/network.py +0 -0
  76. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/mreg_cli/commands/permission.py +0 -0
  77. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/mreg_cli/commands/registry.py +0 -0
  78. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/mreg_cli/commands/root.py +0 -0
  79. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/mreg_cli/commands/zone.py +0 -0
  80. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/mreg_cli/errorbuilder.py +0 -0
  81. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/mreg_cli/help_formatter.py +0 -0
  82. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/mreg_cli/py.typed +0 -0
  83. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/mreg_cli/tags.txt +0 -0
  84. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/mreg_cli/tokenfile.py +0 -0
  85. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/mreg_cli/utilities/__init__.py +0 -0
  86. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/mreg_cli/utilities/shared.py +0 -0
  87. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/mreg_cli.egg-info/dependency_links.txt +0 -0
  88. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/mreg_cli.egg-info/entry_points.txt +0 -0
  89. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/mreg_cli.egg-info/top_level.txt +0 -0
  90. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/setup.cfg +0 -0
  91. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/tests/__init__.py +0 -0
  92. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/tests/api/__init__.py +0 -0
  93. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/tests/api/test_models.py +0 -0
  94. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/tests/test_errorbuilder.py +0 -0
  95. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/tests/test_tokenfile.py +0 -0
  96. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/tests/test_types.py +0 -0
  97. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/tests/utilities/__init__.py +0 -0
  98. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/tests/utilities/test_api.py +0 -0
  99. {mreg_cli-1.6.0 → mreg_cli-1.7.0}/tox.ini +0 -0
@@ -7,6 +7,61 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  <!-- ## Unreleased -->
9
9
 
10
+ ## [1.7.0](https://github.com/unioslo/mreg-cli/releases/tag/1.7.0) - 2025-11-14
11
+
12
+ ### Added
13
+
14
+ - `policy list_hosts -exclude EXCLUDEROLE [EXCLUDEROLE ...]` option to exclude hosts that have the given role(s) when listing hosts with a role. Supports multiple arguments and regular expressions.
15
+ - Environment variable support for all config file options.
16
+ - Environment variables are prefixed with `MREG_CLI_` and use uppercase letters and underscores instead of lowercase letters and hyphens.
17
+ - For example, the `http_timeout` config file option can be set with the `MREG_CLI_HTTP_TIMEOUT` environment variable.
18
+ - Environment variables take precedence over config file options.
19
+ - All config file options and their corresponding environment vaiables:
20
+
21
+ | Config FIle | Env |
22
+ |----------------------------------|-----------------------------------------|
23
+ | `url` | `MREG_CLI_URL` |
24
+ | `user` | `MREG_CLI_USER` |
25
+ | `domain` | `MREG_CLI_DOMAIN` |
26
+ | `timeout` | `MREG_CLI_TIMEOUT` |
27
+ | `prompt` | `MREG_CLI_PROMPT` |
28
+ | `category_tags` | `MREG_CLI_CATEGORY_TAGS` |
29
+ | `location_tags` | `MREG_CLI_LOCATION_TAGS` |
30
+ | `cache` | `MREG_CLI_CACHE` |
31
+ | `cache_ttl` | `MREG_CLI_CACHE_TTL` |
32
+ | `http_timeout` | `MREG_CLI_HTTP_TIMEOUT` |
33
+ | `record_traffic` | `MREG_CLI_RECORD_TRAFFIC` |
34
+ | `record_traffic_without_timestamps` | `MREG_CLI_RECORD_TRAFFIC_WITHOUT_TIMESTAMPS` |
35
+ | `token_only` | `MREG_CLI_TOKEN_ONLY` |
36
+ | `source` | `MREG_CLI_SOURCE` |
37
+ | `verbose` | `MREG_CLI_VERBOSE` |
38
+ | `log_file` | `MREG_CLI_LOG_FILE` |
39
+ | `log_level` | `MREG_CLI_LOG_LEVEL` |
40
+
41
+ - _New_ configuration options, previously only available as CLI options, exposed as both config file options and environment variables:
42
+
43
+ | Config File | Env |
44
+ |----------------------------------|-----------------------------------------|
45
+ | `http_timeout` | `MREG_CLI_HTTP_TIMEOUT` |
46
+ | `record_traffic` | `MREG_CLI_RECORD_TRAFFIC` |
47
+ | `record_traffic_without_timestamps` | `MREG_CLI_RECORD_TRAFFIC_WITHOUT_TIMESTAMPS` |
48
+ | `token_only` | `MREG_CLI_TOKEN_ONLY` |
49
+ | `source` | `MREG_CLI_SOURCE` |
50
+ | `verbose` | `MREG_CLI_VERBOSE` |
51
+ | `log_file`\* | `MREG_CLI_LOG_FILE` |
52
+ | `log_level` | `MREG_CLI_LOG_LEVEL` |
53
+
54
+ \*: The old name `logfile` is deprecated, but remains a valid alias for this field.
55
+
56
+ ### Changed
57
+
58
+ - Default config file location is now `$XDG_CONFIG_HOME/mreg-cli/mreg-cli.conf` (was `~/.config/mreg-cli.conf`).
59
+ - The old config file location remains supported for backwards compatibility, but will be removed in a future release.
60
+ - `help configuration` now shows environment variables.
61
+ - `logging start` no longer toggles logging on and off. Now aborts if logging is already enabled.
62
+ - `logging stop` now aborts if logging is not enabled.
63
+ - `logging status` now shows if logging is enabled or not.
64
+
10
65
  ## [1.6.0](https://github.com/unioslo/mreg-cli/releases/tag/1.6.0) - 2025-10-13
11
66
 
12
67
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mreg-cli
3
- Version: 1.6.0
3
+ Version: 1.7.0
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>
@@ -702,6 +702,7 @@ Requires-Dist: pydantic[email]>=2.7.1
702
702
  Requires-Dist: pydantic-extra-types>=2.1.0
703
703
  Requires-Dist: platformdirs>=4.3.0
704
704
  Requires-Dist: diskcache>=5.6.3
705
+ Requires-Dist: pydantic-settings>=2.10.1
705
706
  Dynamic: license-file
706
707
 
707
708
  # MREG CLI [![Build Status](https://github.com/unioslo/mreg-cli/actions/workflows/test.yml/badge.svg)](https://github.com/unioslo/mreg-cli/actions/workflows/test.yml)
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '1.6.0'
32
- __version_tuple__ = version_tuple = (1, 6, 0)
31
+ __version__ = version = '1.7.0'
32
+ __version_tuple__ = version_tuple = (1, 7, 0)
33
33
 
34
- __commit_id__ = commit_id = 'gaa96abf31'
34
+ __commit_id__ = commit_id = 'g0f525f9e1'
@@ -73,10 +73,10 @@ class HostName(str):
73
73
  return value
74
74
 
75
75
  config = MregCliConfig()
76
- default_domain = config.get("domain")
76
+ domain = config.domain
77
77
  # Append domain name if in config and it does not end with it
78
- if default_domain and not value.endswith(default_domain):
79
- return f"{value}.{default_domain}"
78
+ if domain and not value.endswith(domain):
79
+ return f"{value}.{domain}"
80
80
  return value
81
81
 
82
82
  @classmethod
@@ -35,7 +35,7 @@ from mreg_cli.api.endpoints import Endpoint
35
35
  from mreg_cli.api.fields import HostName, MacAddress, NameList
36
36
  from mreg_cli.api.history import HistoryItem, HistoryResource
37
37
  from mreg_cli.exceptions import (
38
- APINotOk,
38
+ APIError,
39
39
  CreateError,
40
40
  DeleteError,
41
41
  EntityAlreadyExists,
@@ -1012,11 +1012,11 @@ class ForwardZone(Zone, WithName, APIMixin):
1012
1012
  :param hostname: The hostname to search for.
1013
1013
  :returns: The zone if found, None otherwise.
1014
1014
  """
1015
- data = get(Endpoint.ForwardZoneForHost.with_id(hostname), ok404=True)
1016
- if not data:
1015
+ resp = get(Endpoint.ForwardZoneForHost.with_id(hostname), ok404=True)
1016
+ if not resp:
1017
1017
  return None
1018
1018
 
1019
- zoneblob = data.json()
1019
+ zoneblob = resp.json()
1020
1020
 
1021
1021
  if "delegate" in zoneblob:
1022
1022
  return ForwardZoneDelegation.model_validate(zoneblob)
@@ -1027,7 +1027,7 @@ class ForwardZone(Zone, WithName, APIMixin):
1027
1027
  if "delegation" in zoneblob:
1028
1028
  return ForwardZoneDelegation.model_validate(zoneblob["delegation"])
1029
1029
 
1030
- raise UnexpectedDataError(f"Unexpected response from server: {zoneblob}")
1030
+ raise UnexpectedDataError(f"Unexpected response from server: {zoneblob}", resp)
1031
1031
 
1032
1032
 
1033
1033
  class ReverseZone(Zone, WithName, APIMixin):
@@ -1268,15 +1268,28 @@ class Role(HostPolicy, WithHistory):
1268
1268
  for label in labels:
1269
1269
  output_manager.add_formatted_line("", label.name, padding)
1270
1270
 
1271
- def output_hosts(self, _padding: int = 14) -> None:
1271
+ def output_hosts(self, _padding: int = 14, exclude_roles: list[Role] | None = None) -> None:
1272
1272
  """Output the hosts that use the role.
1273
1273
 
1274
1274
  :param padding: Number of spaces for left-padding the output.
1275
+ :param exclude_roles: List of other roles to exclude hosts with.
1275
1276
  """
1276
1277
  manager = OutputManager()
1277
- if self.hosts:
1278
+ hosts = self.hosts
1279
+
1280
+ if exclude_roles:
1281
+ # Exclude any hosts that are found in the excluded roles
1282
+ excluded_hosts: set[str] = set()
1283
+ for host in hosts:
1284
+ for role in exclude_roles:
1285
+ if host in role.hosts:
1286
+ excluded_hosts.add(host)
1287
+ break
1288
+ hosts = [host for host in hosts if host not in excluded_hosts]
1289
+
1290
+ if hosts:
1278
1291
  manager.add_line("Name:")
1279
- for host in self.hosts:
1292
+ for host in hosts:
1280
1293
  manager.add_line(f" {host}")
1281
1294
  else:
1282
1295
  manager.add_line("No host uses this role")
@@ -2463,7 +2476,7 @@ class IPAddress(FrozenModelWithTimestamps, WithHost, APIMixin):
2463
2476
  """Return the network of the IP address."""
2464
2477
  try:
2465
2478
  return get_typed(Endpoint.NetworksByIP.with_id(str(self.ipaddress)), Network)
2466
- except APINotOk:
2479
+ except APIError:
2467
2480
  return None
2468
2481
 
2469
2482
  def vlan(self) -> int | None:
@@ -4545,13 +4558,13 @@ class LDAPHealth(BaseModel, APIMixin):
4545
4558
 
4546
4559
  :param ignore_errors: Ignore non-503 errors. 503 means LDAP is down,
4547
4560
  and should not be treated as an error in the traditional sense.
4548
- :raises requests.APINotOk: If the response code is not 200 or 503.
4561
+ :raises requests.APIError: If the response code is not 200 or 503.
4549
4562
  :returns: An instance of LDAPStatus.
4550
4563
  """
4551
4564
  try:
4552
4565
  get(cls.endpoint())
4553
4566
  return cls(status="OK")
4554
- except APINotOk as e:
4567
+ except APIError as e:
4555
4568
  if ignore_errors:
4556
4569
  logger.error("Failed to fetch LDAP health: %s", e)
4557
4570
  if e.response.status_code == 503:
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import functools
3
4
  import logging
4
5
  from dataclasses import dataclass
5
6
  from types import ModuleType
@@ -72,7 +73,7 @@ class NullCache:
72
73
  tag: str | None = None,
73
74
  ignore: tuple[str, ...] = (),
74
75
  ) -> Callable[[Callable[P, T]], Callable[P, T]]:
75
- """Returns a decorator that does nothing"""
76
+ """Returns a decorator that does nothing."""
76
77
 
77
78
  def decorator(func: Callable[P, T]) -> Callable[P, T]:
78
79
  return func # Just return the original function unchanged
@@ -148,7 +149,7 @@ def _create_cache(config: MregCliConfig) -> CacheLike:
148
149
 
149
150
  Falls back to a no-op cache object if the diskcache cache cannot be created.
150
151
  """
151
- if not config.get_cache_enabled():
152
+ if not config.cache:
152
153
  logger.debug("Cache is disabled in configuration, using NullCache.")
153
154
  return NullCache()
154
155
 
@@ -168,7 +169,7 @@ def configure(config: MregCliConfig) -> None:
168
169
  cache = _create_cache(config)
169
170
  _CACHE = MregCliCache(cache) # pyright: ignore[reportConstantRedefinition]
170
171
 
171
- if not config.get_cache_enabled():
172
+ if not config.cache:
172
173
  logger.debug("Cache is disabled in configuration, not configuring cache.")
173
174
  return
174
175
 
@@ -227,7 +228,7 @@ class MregCliCache:
227
228
  misses=misses,
228
229
  items=len(self.cache),
229
230
  directory=self.cache.directory,
230
- ttl=conf.get_cache_ttl(),
231
+ ttl=conf.cache_ttl,
231
232
  )
232
233
 
233
234
  def _patch_functions(self) -> None:
@@ -243,10 +244,11 @@ class MregCliCache:
243
244
  self._patch_functions()
244
245
  logger.info("Cache enabled")
245
246
 
246
- def disable(self) -> None:
247
+ def disable(self, *, clear: bool = True) -> None:
247
248
  """Disable caching by restoring patched functions and clearing the cache."""
248
249
  self._unpatch_functions()
249
- self.clear()
250
+ if clear:
251
+ self.clear()
250
252
  logger.info("Cache disabled")
251
253
 
252
254
  def clear(self) -> None:
@@ -298,8 +300,6 @@ class MregCliCache:
298
300
 
299
301
  def memoize_function(self, memfunc: MemoizedFunction) -> None:
300
302
  """Memoize a given function."""
301
- from mreg_cli.config import MregCliConfig
302
-
303
303
  config = MregCliConfig()
304
304
 
305
305
  mod_path = memfunc.func
@@ -314,5 +314,20 @@ class MregCliCache:
314
314
  self.save_original_function(mod_path, mod, func)
315
315
 
316
316
  self.patch_function(
317
- mod, self.cache.memoize(expire=config.get_cache_ttl(), tag=memfunc.tag)(func)
317
+ mod, self.cache.memoize(expire=config.cache_ttl, tag=memfunc.tag)(func)
318
318
  )
319
+
320
+
321
+ def disable_cache(func: Callable[P, T]) -> Callable[P, T]:
322
+ """Disable cache for the duration of the decorated function."""
323
+
324
+ @functools.wraps(func)
325
+ def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
326
+ cache = get_cache()
327
+ try:
328
+ cache.disable(clear=False)
329
+ return func(*args, **kwargs)
330
+ finally:
331
+ cache.enable()
332
+
333
+ return wrapper
@@ -12,6 +12,7 @@ import os
12
12
  import shlex
13
13
  import sys
14
14
  from collections.abc import Generator
15
+ from pathlib import Path
15
16
  from typing import TYPE_CHECKING, Any, Protocol
16
17
 
17
18
  from prompt_toolkit import HTML, document, print_formatted_text
@@ -39,6 +40,7 @@ from mreg_cli.help_formatter import CustomHelpFormatter
39
40
  from mreg_cli.outputmanager import OutputManager
40
41
  from mreg_cli.types import CommandFunc, Flag
41
42
  from mreg_cli.utilities.api import create_and_set_corrolation_id, last_request_url
43
+ from mreg_cli.utilities.fs import to_path
42
44
 
43
45
  logger = logging.getLogger(__name__)
44
46
 
@@ -307,7 +309,7 @@ for command in commands:
307
309
  command(cli).register_all_commands()
308
310
 
309
311
 
310
- def source(files: list[str], ignore_errors: bool, verbose: bool) -> Generator[str]:
312
+ def source(files: list[Path], ignore_errors: bool, verbose: bool) -> Generator[str]:
311
313
  """Read commands from one or more source files and yield them.
312
314
 
313
315
  :param files: List of file paths to read commands from.
@@ -317,8 +319,7 @@ def source(files: list[str], ignore_errors: bool, verbose: bool) -> Generator[st
317
319
  :yields: Command lines from the files.
318
320
  """
319
321
  for filename in files:
320
- if filename.startswith("~"):
321
- filename = os.path.expanduser(filename)
322
+ filename = filename.expanduser()
322
323
  try:
323
324
  with open(filename) as f:
324
325
  logger.info("Reading commands from %s", filename)
@@ -357,7 +358,8 @@ def _source(args: argparse.Namespace):
357
358
 
358
359
  :param args: The arguments passed to the command.
359
360
  """
360
- for command in source(args.files, args.ignore_errors, args.verbose):
361
+ files = [to_path(f) for f in args.files]
362
+ for command in source(files, args.ignore_errors, args.verbose):
361
363
  # Process each command here as needed, similar to the main loop
362
364
  print(f"Processing command: {command}")
363
365
  cli.process_command_line(command)
@@ -43,7 +43,7 @@ def cache_clear(_: argparse.Namespace) -> None:
43
43
  def cache_info(_: argparse.Namespace) -> None:
44
44
  """Show cache information."""
45
45
  conf = MregCliConfig()
46
- if not conf.get_cache_enabled():
46
+ if not conf.cache:
47
47
  OutputManager().add_line("Cache is disabled")
48
48
  return
49
49
 
@@ -61,13 +61,13 @@ def cache_info(_: argparse.Namespace) -> None:
61
61
  def cache_enable(_: argparse.Namespace) -> None:
62
62
  """Enable caching."""
63
63
  conf = MregCliConfig()
64
- if conf.get_cache_enabled():
64
+ if conf.cache:
65
65
  OutputManager().add_line("Cache is already enabled")
66
66
  return
67
67
 
68
68
  cache = get_cache()
69
69
  cache.enable()
70
- conf.set_cache_enabled(True)
70
+ conf.cache = True
71
71
  OutputManager().add_ok("Enabled cache")
72
72
 
73
73
 
@@ -77,11 +77,11 @@ def cache_enable(_: argparse.Namespace) -> None:
77
77
  def cache_disable(_: argparse.Namespace) -> None:
78
78
  """Disable caching."""
79
79
  conf = MregCliConfig()
80
- if not conf.get_cache_enabled():
80
+ if not conf.cache:
81
81
  OutputManager().add_line("Cache is already disabled")
82
82
  return
83
83
 
84
84
  cache = get_cache()
85
85
  cache.disable()
86
- conf.set_cache_enabled(False)
86
+ conf.cache = False
87
87
  OutputManager().add_ok("Cleared and disabled cache")
@@ -7,6 +7,7 @@ from typing import Any
7
7
 
8
8
  from mreg_cli.__about__ import __version__ as mreg_cli_version
9
9
  from mreg_cli.api.models import HealthInfo, ServerLibraries, ServerVersion, UserInfo
10
+ from mreg_cli.cache import disable_cache
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
@@ -91,6 +92,7 @@ def configuration_help(_: argparse.Namespace) -> None:
91
92
  description="Show versions of client and server as much as possible",
92
93
  short_desc="Show versions",
93
94
  )
95
+ @disable_cache
94
96
  def versions_help(_: argparse.Namespace) -> None:
95
97
  """Show versions of client and server as much as possible."""
96
98
  output_manager = OutputManager()
@@ -108,13 +110,14 @@ def versions_help(_: argparse.Namespace) -> None:
108
110
  Flag("-django", action="store_true", description="Show Django internal roles"),
109
111
  ],
110
112
  )
113
+ @disable_cache
111
114
  def whoami_help(args: argparse.Namespace) -> None:
112
115
  """Show information about the current user."""
113
116
  try:
114
117
  UserInfo.fetch(ignore_errors=False).output(django=args.django)
115
118
  except Exception as e:
116
119
  raise CliError(
117
- f"Failed to display user info for current user {MregCliConfig().get_user()!r}: {e}"
120
+ f"Failed to display user info for current user {MregCliConfig().user!r}: {e}"
118
121
  ) from e
119
122
 
120
123
 
@@ -127,6 +130,7 @@ def whoami_help(args: argparse.Namespace) -> None:
127
130
  Flag("-django", action="store_true", description="Show Django internal roles"),
128
131
  ],
129
132
  )
133
+ @disable_cache
130
134
  def whois_help(args: argparse.Namespace) -> None:
131
135
  """Show information about a user."""
132
136
  try:
@@ -140,6 +144,7 @@ def whois_help(args: argparse.Namespace) -> None:
140
144
  description="Show information about the health of the server",
141
145
  short_desc="Show health info",
142
146
  )
147
+ @disable_cache
143
148
  def meta_health(_: argparse.Namespace) -> None:
144
149
  """Show information about the health of the server."""
145
150
  try:
@@ -28,7 +28,7 @@ from mreg_cli.api.models import (
28
28
  )
29
29
  from mreg_cli.commands.host import registry as command_registry
30
30
  from mreg_cli.exceptions import (
31
- APINotOk,
31
+ APIError,
32
32
  CreateError,
33
33
  DeleteError,
34
34
  EntityAlreadyExists,
@@ -149,7 +149,7 @@ def add(args: argparse.Namespace) -> None:
149
149
  raise InvalidIPAddress(
150
150
  f"IP {ipaddr} is a broadcast address, not a host address, must force"
151
151
  )
152
- except (EntityNotFound, APINotOk) as e:
152
+ except (EntityNotFound, APIError) as e:
153
153
  if not force:
154
154
  raise ForceMissing(f"IP {ipaddr} is not in a network, must force") from e
155
155
  data["ipaddress"] = str(network_or_ip)
@@ -3,15 +3,16 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import argparse
6
- import os
7
6
  from typing import Any
8
7
 
9
8
  from mreg_cli.commands.base import BaseCommand
10
9
  from mreg_cli.commands.registry import CommandRegistry
11
10
  from mreg_cli.config import MregCliConfig
12
- from mreg_cli.exceptions import InputFailure
11
+ from mreg_cli.exceptions import FileError, InputFailure
12
+ from mreg_cli.log import MregCliLogger
13
13
  from mreg_cli.outputmanager import OutputManager
14
14
  from mreg_cli.types import Flag, LogLevel
15
+ from mreg_cli.utilities.fs import to_path
15
16
  from mreg_cli.utilities.shared import sizeof_fmt
16
17
 
17
18
  command_registry = CommandRegistry()
@@ -37,13 +38,14 @@ class LoggingCommmands(BaseCommand):
37
38
  short_desc="Start logging",
38
39
  flags=[
39
40
  Flag(
40
- "filename",
41
- description="The filename to log to.",
41
+ "-filename",
42
+ description="The filename to log to. Uses default log file if not given.",
42
43
  short_desc="Filename",
43
44
  metavar="filename",
45
+ default=None,
44
46
  ),
45
47
  Flag(
46
- "level",
48
+ "-level",
47
49
  description="The logging level to use.",
48
50
  short_desc="Level",
49
51
  metavar="level",
@@ -54,12 +56,20 @@ class LoggingCommmands(BaseCommand):
54
56
  )
55
57
  def start_logging(args: argparse.Namespace) -> None:
56
58
  """Start logging to the given file."""
57
- if not args.filename:
58
- raise InputFailure("No filename given.")
59
+ if args.filename is not None:
60
+ log_file = to_path(args.filename)
61
+ else:
62
+ log_file = MregCliConfig().log_file
63
+ level = LogLevel(args.level)
59
64
 
60
- config = MregCliConfig()
61
- config.stop_logging()
62
- config.start_logging(args.filename, args.level)
65
+ log = MregCliLogger()
66
+ status = log.status
67
+ # Abort if already logging with same params
68
+ if status.enabled and status.file == log_file and status.level == level:
69
+ raise InputFailure(f"Logging already enabled: {level} > {log_file}")
70
+
71
+ log.start_logging(log_file, level)
72
+ OutputManager().add_line(f"Logging started: {level} > {log_file}")
63
73
 
64
74
 
65
75
  @command_registry.register_command(
@@ -69,7 +79,12 @@ def start_logging(args: argparse.Namespace) -> None:
69
79
  )
70
80
  def stop_logging(_: argparse.Namespace):
71
81
  """Stop logging."""
72
- MregCliConfig().stop_logging()
82
+ log = MregCliLogger()
83
+ if not log.status.enabled:
84
+ raise InputFailure("Logging is not enabled.")
85
+
86
+ log.stop_logging()
87
+ OutputManager().add_line("Logging stopped.")
73
88
 
74
89
 
75
90
  @command_registry.register_command(
@@ -89,8 +104,15 @@ def stop_logging(_: argparse.Namespace):
89
104
  def set_logging_level(args: argparse.Namespace) -> None:
90
105
  """Set the logging level."""
91
106
  config = MregCliConfig()
92
- config.stop_logging()
93
- config.start_logging(config.get_default_logfile(), args.level)
107
+ log = MregCliLogger()
108
+
109
+ if not log.status.enabled:
110
+ raise InputFailure("Logging is not enabled, cannot set level.")
111
+
112
+ # Determine logging params
113
+ log_file = log.status.file or config.log_file # fallback should never be reachable...
114
+ level = LogLevel(args.level)
115
+ log.start_logging(log_file, level)
94
116
 
95
117
 
96
118
  @command_registry.register_command(
@@ -100,13 +122,22 @@ def set_logging_level(args: argparse.Namespace) -> None:
100
122
  )
101
123
  def logging_status(_: argparse.Namespace):
102
124
  """Print the logging status."""
103
- level = MregCliConfig().get_logging_level()
104
- file = MregCliConfig().get_default_logfile()
125
+ conf = MregCliConfig()
126
+
127
+ status = MregCliLogger().status
128
+ if not status.enabled:
129
+ raise InputFailure("Logging is disabled.")
130
+
131
+ level = status.level
132
+ file = status.file or conf.log_file # fallback should never be reachable...
105
133
 
106
134
  lines_in_logfile = 0
107
- with open(file) as f:
108
- lines_in_logfile = sum(1 for _ in f)
135
+ try:
136
+ with open(file) as f:
137
+ lines_in_logfile = sum(1 for _ in f)
138
+ except OSError as e:
139
+ raise FileError(f"Unable to read log file {file}: {e}") from e
109
140
 
110
- filesize = sizeof_fmt(os.path.getsize(file))
141
+ filesize = sizeof_fmt(file.stat().st_size)
111
142
 
112
143
  OutputManager().add_line(f"{level} > {file} ({lines_in_logfile} lines, {filesize})")
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import argparse
6
+ import itertools
6
7
  from typing import Any
7
8
 
8
9
  from mreg_cli.api.models import (
@@ -13,7 +14,7 @@ from mreg_cli.api.models import (
13
14
  )
14
15
  from mreg_cli.commands.base import BaseCommand
15
16
  from mreg_cli.commands.registry import CommandRegistry
16
- from mreg_cli.exceptions import APINotOk, CreateError, DeleteError, EntityAlreadyExists
17
+ from mreg_cli.exceptions import APIError, CreateError, DeleteError, EntityAlreadyExists, PatchError
17
18
  from mreg_cli.outputmanager import OutputManager
18
19
  from mreg_cli.types import Flag
19
20
 
@@ -259,6 +260,15 @@ def list_roles(args: argparse.Namespace) -> None:
259
260
  short_desc="List hosts which use the given role",
260
261
  flags=[
261
262
  Flag("name", description="Role name", metavar="ROLE"),
263
+ Flag(
264
+ "-exclude",
265
+ description=(
266
+ "Exclude hosts that have these roles. "
267
+ "Supports regular expressions and multiple arguments."
268
+ ),
269
+ metavar="EXCLUDEROLE",
270
+ nargs="+",
271
+ ),
262
272
  ],
263
273
  )
264
274
  def list_hosts(args: argparse.Namespace) -> None:
@@ -267,9 +277,15 @@ def list_hosts(args: argparse.Namespace) -> None:
267
277
  :param args: argparse.Namespace (name)
268
278
  """
269
279
  name: str = args.name
280
+ exclude: list[str] = args.exclude if args.exclude else []
270
281
 
271
282
  role = Role.get_by_name_or_raise(name)
272
- role.output_hosts()
283
+
284
+ exclude_roles = list(
285
+ itertools.chain.from_iterable(Role.get_list_by_name_regex(r) for r in exclude)
286
+ )
287
+
288
+ role.output_hosts(exclude_roles=exclude_roles)
273
289
 
274
290
 
275
291
  @command_registry.register_command(
@@ -312,18 +328,20 @@ def host_add(args: argparse.Namespace) -> None:
312
328
  hosts = [Host.get_by_any_means_or_raise(host) for host in host_names]
313
329
 
314
330
  for host in hosts:
331
+ # Best-effort approach – try to assign roles to all hosts
315
332
  try:
316
333
  role.add_host(host.name)
317
334
  OutputManager().add_ok(f"Added host {host.name} to role {role_name!r}")
318
- except APINotOk as e:
335
+ except APIError as e:
319
336
  if e.response.status_code == 409:
320
337
  OutputManager().add_line(
321
338
  f"Host {host.name} is already a member of role {role_name!r}"
322
339
  )
323
340
  else:
324
- OutputManager().add_error(
325
- f"Failed to add host {host.name} to role {role_name!r}: {e}"
341
+ err = PatchError.from_api_error(
342
+ e, f"Failed to add host {host.name} to role {role_name!r}"
326
343
  )
344
+ err.print_and_log()
327
345
 
328
346
 
329
347
  @command_registry.register_command(
@@ -10,6 +10,7 @@ from mreg_cli.commands.registry import CommandRegistry
10
10
  from mreg_cli.exceptions import InputFailure
11
11
  from mreg_cli.outputmanager import OutputManager
12
12
  from mreg_cli.types import Flag
13
+ from mreg_cli.utilities.fs import to_path
13
14
 
14
15
  command_registry = CommandRegistry()
15
16
 
@@ -45,8 +46,8 @@ def start_recording(args: argparse.Namespace) -> None:
45
46
  """Start recording commands and output to the given file."""
46
47
  if not args.filename:
47
48
  raise InputFailure("No filename given.")
48
-
49
- OutputManager().recording_start(args.filename)
49
+ path = to_path(args.filename)
50
+ OutputManager().recording_start(path)
50
51
 
51
52
 
52
53
  @command_registry.register_command(