infrahub-server 1.5.4__py3-none-any.whl → 1.6.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.
Files changed (129) hide show
  1. infrahub/api/artifact.py +5 -3
  2. infrahub/auth.py +5 -6
  3. infrahub/cli/db.py +3 -3
  4. infrahub/cli/db_commands/clean_duplicate_schema_fields.py +2 -2
  5. infrahub/cli/dev.py +30 -0
  6. infrahub/config.py +62 -14
  7. infrahub/constants/database.py +5 -5
  8. infrahub/core/branch/models.py +24 -6
  9. infrahub/core/constants/__init__.py +1 -0
  10. infrahub/core/diff/model/diff.py +2 -2
  11. infrahub/core/graph/constraints.py +2 -2
  12. infrahub/core/manager.py +191 -60
  13. infrahub/core/merge.py +29 -2
  14. infrahub/core/migrations/shared.py +2 -2
  15. infrahub/core/models.py +5 -6
  16. infrahub/core/node/__init__.py +12 -6
  17. infrahub/core/node/create.py +36 -8
  18. infrahub/core/node/ipam.py +4 -4
  19. infrahub/core/node/node_property_attribute.py +2 -2
  20. infrahub/core/node/standard.py +1 -1
  21. infrahub/core/query/attribute.py +1 -1
  22. infrahub/core/query/branch.py +11 -0
  23. infrahub/core/query/node.py +9 -5
  24. infrahub/core/query/standard_node.py +3 -0
  25. infrahub/core/relationship/model.py +15 -10
  26. infrahub/core/schema/__init__.py +3 -3
  27. infrahub/core/schema/generic_schema.py +1 -1
  28. infrahub/core/schema/schema_branch.py +35 -16
  29. infrahub/core/task/user_task.py +2 -2
  30. infrahub/core/validators/determiner.py +3 -6
  31. infrahub/core/validators/enum.py +2 -2
  32. infrahub/database/__init__.py +1 -1
  33. infrahub/dependencies/interface.py +2 -2
  34. infrahub/events/constants.py +2 -2
  35. infrahub/git/base.py +42 -1
  36. infrahub/git/models.py +2 -1
  37. infrahub/git/repository.py +5 -1
  38. infrahub/git/tasks.py +28 -1
  39. infrahub/git/utils.py +9 -0
  40. infrahub/graphql/analyzer.py +4 -4
  41. infrahub/graphql/loaders/peers.py +6 -0
  42. infrahub/graphql/mutations/computed_attribute.py +1 -1
  43. infrahub/graphql/mutations/convert_object_type.py +1 -1
  44. infrahub/graphql/mutations/display_label.py +1 -1
  45. infrahub/graphql/mutations/hfid.py +1 -1
  46. infrahub/graphql/mutations/ipam.py +1 -1
  47. infrahub/graphql/mutations/profile.py +9 -1
  48. infrahub/graphql/mutations/relationship.py +2 -2
  49. infrahub/graphql/mutations/resource_manager.py +1 -1
  50. infrahub/graphql/queries/__init__.py +2 -1
  51. infrahub/graphql/queries/branch.py +58 -3
  52. infrahub/graphql/queries/ipam.py +9 -4
  53. infrahub/graphql/queries/resource_manager.py +7 -11
  54. infrahub/graphql/queries/search.py +5 -6
  55. infrahub/graphql/resolvers/ipam.py +20 -0
  56. infrahub/graphql/resolvers/many_relationship.py +12 -11
  57. infrahub/graphql/resolvers/resolver.py +6 -2
  58. infrahub/graphql/resolvers/single_relationship.py +1 -11
  59. infrahub/graphql/schema.py +2 -0
  60. infrahub/graphql/types/__init__.py +3 -1
  61. infrahub/graphql/types/branch.py +98 -2
  62. infrahub/lock.py +6 -6
  63. infrahub/log.py +1 -1
  64. infrahub/message_bus/messages/__init__.py +0 -12
  65. infrahub/patch/constants.py +2 -2
  66. infrahub/profiles/node_applier.py +9 -0
  67. infrahub/proposed_change/tasks.py +1 -1
  68. infrahub/task_manager/task.py +4 -4
  69. infrahub/telemetry/constants.py +2 -2
  70. infrahub/trigger/models.py +2 -2
  71. infrahub/trigger/setup.py +6 -9
  72. infrahub/utils.py +19 -1
  73. infrahub/validators/tasks.py +1 -1
  74. infrahub/workers/infrahub_async.py +39 -1
  75. infrahub_sdk/async_typer.py +2 -1
  76. infrahub_sdk/batch.py +2 -2
  77. infrahub_sdk/client.py +121 -10
  78. infrahub_sdk/config.py +2 -2
  79. infrahub_sdk/ctl/branch.py +176 -2
  80. infrahub_sdk/ctl/check.py +3 -3
  81. infrahub_sdk/ctl/cli.py +2 -2
  82. infrahub_sdk/ctl/cli_commands.py +10 -9
  83. infrahub_sdk/ctl/generator.py +2 -2
  84. infrahub_sdk/ctl/graphql.py +3 -4
  85. infrahub_sdk/ctl/importer.py +2 -3
  86. infrahub_sdk/ctl/repository.py +5 -6
  87. infrahub_sdk/ctl/task.py +2 -4
  88. infrahub_sdk/ctl/utils.py +4 -4
  89. infrahub_sdk/ctl/validate.py +1 -2
  90. infrahub_sdk/diff.py +80 -3
  91. infrahub_sdk/graphql/constants.py +14 -1
  92. infrahub_sdk/graphql/renderers.py +5 -1
  93. infrahub_sdk/node/attribute.py +10 -10
  94. infrahub_sdk/node/constants.py +2 -3
  95. infrahub_sdk/node/node.py +54 -11
  96. infrahub_sdk/node/related_node.py +1 -2
  97. infrahub_sdk/node/relationship.py +1 -2
  98. infrahub_sdk/object_store.py +4 -4
  99. infrahub_sdk/operation.py +2 -2
  100. infrahub_sdk/protocols_base.py +0 -1
  101. infrahub_sdk/protocols_generator/generator.py +1 -1
  102. infrahub_sdk/pytest_plugin/items/jinja2_transform.py +1 -1
  103. infrahub_sdk/pytest_plugin/models.py +1 -1
  104. infrahub_sdk/pytest_plugin/plugin.py +1 -1
  105. infrahub_sdk/query_groups.py +2 -2
  106. infrahub_sdk/schema/__init__.py +10 -14
  107. infrahub_sdk/schema/main.py +2 -2
  108. infrahub_sdk/schema/repository.py +2 -2
  109. infrahub_sdk/spec/object.py +2 -2
  110. infrahub_sdk/spec/range_expansion.py +1 -1
  111. infrahub_sdk/template/__init__.py +2 -1
  112. infrahub_sdk/transfer/importer/json.py +3 -3
  113. infrahub_sdk/types.py +2 -2
  114. infrahub_sdk/utils.py +2 -2
  115. {infrahub_server-1.5.4.dist-info → infrahub_server-1.6.0.dist-info}/METADATA +58 -59
  116. {infrahub_server-1.5.4.dist-info → infrahub_server-1.6.0.dist-info}/RECORD +239 -245
  117. {infrahub_server-1.5.4.dist-info → infrahub_server-1.6.0.dist-info}/WHEEL +1 -1
  118. infrahub_server-1.6.0.dist-info/entry_points.txt +12 -0
  119. infrahub_testcontainers/container.py +2 -2
  120. infrahub_testcontainers/docker-compose-cluster.test.yml +1 -1
  121. infrahub_testcontainers/docker-compose.test.yml +1 -1
  122. infrahub/core/schema/generated/__init__.py +0 -0
  123. infrahub/core/schema/generated/attribute_schema.py +0 -133
  124. infrahub/core/schema/generated/base_node_schema.py +0 -111
  125. infrahub/core/schema/generated/genericnode_schema.py +0 -30
  126. infrahub/core/schema/generated/node_schema.py +0 -40
  127. infrahub/core/schema/generated/relationship_schema.py +0 -141
  128. infrahub_server-1.5.4.dist-info/entry_points.txt +0 -13
  129. {infrahub_server-1.5.4.dist-info → infrahub_server-1.6.0.dist-info/licenses}/LICENSE.txt +0 -0
infrahub_sdk/client.py CHANGED
@@ -5,14 +5,13 @@ import copy
5
5
  import logging
6
6
  import time
7
7
  import warnings
8
- from collections.abc import Coroutine, Mapping, MutableMapping
8
+ from collections.abc import Callable, Coroutine, Mapping, MutableMapping
9
9
  from datetime import datetime
10
10
  from functools import wraps
11
11
  from time import sleep
12
12
  from typing import (
13
13
  TYPE_CHECKING,
14
14
  Any,
15
- Callable,
16
15
  Literal,
17
16
  TypedDict,
18
17
  TypeVar,
@@ -35,7 +34,7 @@ from .config import Config
35
34
  from .constants import InfrahubClientMode
36
35
  from .convert_object_type import CONVERT_OBJECT_MUTATION, ConversionFieldInput
37
36
  from .data import RepositoryBranchInfo, RepositoryData
38
- from .diff import NodeDiff, diff_tree_node_to_node_diff, get_diff_summary_query
37
+ from .diff import DiffTreeData, NodeDiff, diff_tree_node_to_node_diff, get_diff_summary_query, get_diff_tree_query
39
38
  from .exceptions import (
40
39
  AuthenticationError,
41
40
  Error,
@@ -300,7 +299,7 @@ class BaseClient:
300
299
  if prefix_length:
301
300
  input_data["prefix_length"] = prefix_length
302
301
  if member_type:
303
- if member_type not in ("prefix", "address"):
302
+ if member_type not in {"prefix", "address"}:
304
303
  raise ValueError("member_type possible values are 'prefix' or 'address'")
305
304
  input_data["member_type"] = member_type
306
305
  if prefix_type:
@@ -957,7 +956,7 @@ class InfrahubClient(BaseClient):
957
956
  try:
958
957
  resp = await self._post(url=url, payload=payload, headers=headers, timeout=timeout)
959
958
 
960
- if raise_for_error in (None, True):
959
+ if raise_for_error in {None, True}:
961
960
  resp.raise_for_status()
962
961
 
963
962
  retry = False
@@ -971,7 +970,7 @@ class InfrahubClient(BaseClient):
971
970
  self.log.error(f"Unable to connect to {self.address} .. ")
972
971
  raise
973
972
  except httpx.HTTPStatusError as exc:
974
- if exc.response.status_code in [401, 403]:
973
+ if exc.response.status_code in {401, 403}:
975
974
  response = decode_json(response=exc.response)
976
975
  errors = response.get("errors", [])
977
976
  messages = [error.get("message") for error in errors]
@@ -1209,7 +1208,7 @@ class InfrahubClient(BaseClient):
1209
1208
  timeout=timeout or self.default_timeout,
1210
1209
  )
1211
1210
 
1212
- if raise_for_error in (None, True):
1211
+ if raise_for_error in {None, True}:
1213
1212
  resp.raise_for_status()
1214
1213
 
1215
1214
  return decode_json(response=resp)
@@ -1283,6 +1282,62 @@ class InfrahubClient(BaseClient):
1283
1282
 
1284
1283
  return node_diffs
1285
1284
 
1285
+ async def get_diff_tree(
1286
+ self,
1287
+ branch: str,
1288
+ name: str | None = None,
1289
+ from_time: datetime | None = None,
1290
+ to_time: datetime | None = None,
1291
+ timeout: int | None = None,
1292
+ tracker: str | None = None,
1293
+ ) -> DiffTreeData | None:
1294
+ """Get complete diff tree with metadata and nodes.
1295
+
1296
+ Returns None if no diff exists.
1297
+ """
1298
+ query = get_diff_tree_query()
1299
+ input_data = {"branch_name": branch}
1300
+ if name:
1301
+ input_data["name"] = name
1302
+ if from_time and to_time and from_time > to_time:
1303
+ raise ValueError("from_time must be <= to_time")
1304
+ if from_time:
1305
+ input_data["from_time"] = from_time.isoformat()
1306
+ if to_time:
1307
+ input_data["to_time"] = to_time.isoformat()
1308
+
1309
+ response = await self.execute_graphql(
1310
+ query=query.render(),
1311
+ branch_name=branch,
1312
+ timeout=timeout,
1313
+ tracker=tracker,
1314
+ variables=input_data,
1315
+ )
1316
+
1317
+ diff_tree = response["DiffTree"]
1318
+ if diff_tree is None:
1319
+ return None
1320
+
1321
+ # Convert nodes to NodeDiff objects
1322
+ node_diffs: list[NodeDiff] = []
1323
+ if "nodes" in diff_tree:
1324
+ for node_dict in diff_tree["nodes"]:
1325
+ node_diff = diff_tree_node_to_node_diff(node_dict=node_dict, branch_name=branch)
1326
+ node_diffs.append(node_diff)
1327
+
1328
+ return DiffTreeData(
1329
+ num_added=diff_tree.get("num_added") or 0,
1330
+ num_updated=diff_tree.get("num_updated") or 0,
1331
+ num_removed=diff_tree.get("num_removed") or 0,
1332
+ num_conflicts=diff_tree.get("num_conflicts") or 0,
1333
+ to_time=diff_tree["to_time"],
1334
+ from_time=diff_tree["from_time"],
1335
+ base_branch=diff_tree["base_branch"],
1336
+ diff_branch=diff_tree["diff_branch"],
1337
+ name=diff_tree.get("name"),
1338
+ nodes=node_diffs,
1339
+ )
1340
+
1286
1341
  @overload
1287
1342
  async def allocate_next_ip_address(
1288
1343
  self,
@@ -1818,7 +1873,7 @@ class InfrahubClientSync(BaseClient):
1818
1873
  try:
1819
1874
  resp = self._post(url=url, payload=payload, headers=headers, timeout=timeout)
1820
1875
 
1821
- if raise_for_error in (None, True):
1876
+ if raise_for_error in {None, True}:
1822
1877
  resp.raise_for_status()
1823
1878
 
1824
1879
  retry = False
@@ -1832,7 +1887,7 @@ class InfrahubClientSync(BaseClient):
1832
1887
  self.log.error(f"Unable to connect to {self.address} .. ")
1833
1888
  raise
1834
1889
  except httpx.HTTPStatusError as exc:
1835
- if exc.response.status_code in [401, 403]:
1890
+ if exc.response.status_code in {401, 403}:
1836
1891
  response = decode_json(response=exc.response)
1837
1892
  errors = response.get("errors", [])
1838
1893
  messages = [error.get("message") for error in errors]
@@ -2447,7 +2502,7 @@ class InfrahubClientSync(BaseClient):
2447
2502
  timeout=timeout or self.default_timeout,
2448
2503
  )
2449
2504
 
2450
- if raise_for_error in (None, True):
2505
+ if raise_for_error in {None, True}:
2451
2506
  resp.raise_for_status()
2452
2507
 
2453
2508
  return decode_json(response=resp)
@@ -2521,6 +2576,62 @@ class InfrahubClientSync(BaseClient):
2521
2576
 
2522
2577
  return node_diffs
2523
2578
 
2579
+ def get_diff_tree(
2580
+ self,
2581
+ branch: str,
2582
+ name: str | None = None,
2583
+ from_time: datetime | None = None,
2584
+ to_time: datetime | None = None,
2585
+ timeout: int | None = None,
2586
+ tracker: str | None = None,
2587
+ ) -> DiffTreeData | None:
2588
+ """Get complete diff tree with metadata and nodes.
2589
+
2590
+ Returns None if no diff exists.
2591
+ """
2592
+ query = get_diff_tree_query()
2593
+ input_data = {"branch_name": branch}
2594
+ if name:
2595
+ input_data["name"] = name
2596
+ if from_time and to_time and from_time > to_time:
2597
+ raise ValueError("from_time must be <= to_time")
2598
+ if from_time:
2599
+ input_data["from_time"] = from_time.isoformat()
2600
+ if to_time:
2601
+ input_data["to_time"] = to_time.isoformat()
2602
+
2603
+ response = self.execute_graphql(
2604
+ query=query.render(),
2605
+ branch_name=branch,
2606
+ timeout=timeout,
2607
+ tracker=tracker,
2608
+ variables=input_data,
2609
+ )
2610
+
2611
+ diff_tree = response["DiffTree"]
2612
+ if diff_tree is None:
2613
+ return None
2614
+
2615
+ # Convert nodes to NodeDiff objects
2616
+ node_diffs: list[NodeDiff] = []
2617
+ if "nodes" in diff_tree:
2618
+ for node_dict in diff_tree["nodes"]:
2619
+ node_diff = diff_tree_node_to_node_diff(node_dict=node_dict, branch_name=branch)
2620
+ node_diffs.append(node_diff)
2621
+
2622
+ return DiffTreeData(
2623
+ num_added=diff_tree.get("num_added") or 0,
2624
+ num_updated=diff_tree.get("num_updated") or 0,
2625
+ num_removed=diff_tree.get("num_removed") or 0,
2626
+ num_conflicts=diff_tree.get("num_conflicts") or 0,
2627
+ to_time=diff_tree["to_time"],
2628
+ from_time=diff_tree["from_time"],
2629
+ base_branch=diff_tree["base_branch"],
2630
+ diff_branch=diff_tree["diff_branch"],
2631
+ name=diff_tree.get("name"),
2632
+ nodes=node_diffs,
2633
+ )
2634
+
2524
2635
  @overload
2525
2636
  def allocate_next_ip_address(
2526
2637
  self,
infrahub_sdk/config.py CHANGED
@@ -173,7 +173,7 @@ class Config(ConfigBase):
173
173
  # When using structlog the logger doesn't expose the expected methods by looking at the
174
174
  # object to pydantic rejects them. This is a workaround to allow structlog to be used
175
175
  # as a logger
176
- return self.log # type: ignore
176
+ return self.log # type: ignore[return-value]
177
177
 
178
178
  @model_validator(mode="before")
179
179
  @classmethod
@@ -194,7 +194,7 @@ class Config(ConfigBase):
194
194
  "log": self.log,
195
195
  }
196
196
  covered_keys = list(config.keys())
197
- for field in Config.model_fields.keys():
197
+ for field in Config.model_fields:
198
198
  if field not in covered_keys:
199
199
  config[field] = deepcopy(getattr(self, field))
200
200
 
@@ -1,15 +1,24 @@
1
1
  import logging
2
+ import sys
3
+ from datetime import datetime, timezone
4
+ from typing import TYPE_CHECKING
2
5
 
3
6
  import typer
4
7
  from rich.console import Console
5
8
  from rich.table import Table
6
9
 
7
10
  from ..async_typer import AsyncTyper
8
- from ..utils import calculate_time_diff
11
+ from ..branch import BranchData
12
+ from ..diff import DiffTreeData
13
+ from ..protocols import CoreProposedChange
14
+ from ..utils import calculate_time_diff, decode_json
9
15
  from .client import initialize_client
10
16
  from .parameters import CONFIG_PARAM
11
17
  from .utils import catch_exception
12
18
 
19
+ if TYPE_CHECKING:
20
+ from ..client import InfrahubClient
21
+
13
22
  app = AsyncTyper()
14
23
  console = Console()
15
24
 
@@ -18,6 +27,108 @@ DEFAULT_CONFIG_FILE = "infrahubctl.toml"
18
27
  ENVVAR_CONFIG_FILE = "INFRAHUBCTL_CONFIG"
19
28
 
20
29
 
30
+ def format_timestamp(timestamp: str) -> str:
31
+ """Format ISO timestamp to 'YYYY-MM-DD HH:MM:SS'.
32
+ Args:
33
+ timestamp (str): ISO fromatted timestamp
34
+
35
+ Returns:
36
+ (str): the datetime as string formatted as 'YYYY-MM-DD HH:MM:SS'
37
+
38
+ Raises:
39
+ Any execptions returned from formatting the timestamp are propogated to the caller
40
+ """
41
+ dt = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
42
+ return dt.strftime("%Y-%m-%d %H:%M:%S")
43
+
44
+
45
+ async def check_git_files_changed(client: "InfrahubClient", branch: str) -> bool:
46
+ """Check if there are any Git file changes in a branch.
47
+
48
+ Args:
49
+ client: Infrahub client instance
50
+ branch: Branch name to check
51
+
52
+ Returns:
53
+ True if files have changed, False otherwise
54
+
55
+ Raises:
56
+ Any exceptions from the API call are propagated to the caller
57
+ """
58
+ url = f"{client.address}/api/diff/files?branch={branch}"
59
+ resp = await client._get(url=url, timeout=client.default_timeout)
60
+ resp.raise_for_status()
61
+ data = decode_json(response=resp)
62
+
63
+ # Check if any repository has files
64
+ if branch in data:
65
+ for repo_data in data[branch].values():
66
+ if isinstance(repo_data, dict) and "files" in repo_data and len(repo_data["files"]) > 0:
67
+ return True
68
+
69
+ return False
70
+
71
+
72
+ def generate_branch_report_table(
73
+ branch: BranchData, diff_tree: DiffTreeData | None, git_files_changed: bool | None
74
+ ) -> Table:
75
+ branch_table = Table(show_header=False, box=None)
76
+ branch_table.add_column(justify="left")
77
+ branch_table.add_column(justify="right")
78
+
79
+ branch_table.add_row("Created at", format_timestamp(branch.branched_from))
80
+
81
+ status_value = branch.status.value if hasattr(branch.status, "value") else str(branch.status)
82
+ branch_table.add_row("Status", status_value)
83
+
84
+ branch_table.add_row("Synced with Git", "Yes" if branch.sync_with_git else "No")
85
+
86
+ if git_files_changed is not None:
87
+ branch_table.add_row("Git files changed", "Yes" if git_files_changed else "No")
88
+ else:
89
+ branch_table.add_row("Git files changed", "N/A")
90
+
91
+ branch_table.add_row("Has schema changes", "Yes" if branch.has_schema_changes else "No")
92
+
93
+ if diff_tree:
94
+ branch_table.add_row("Diff last updated", format_timestamp(diff_tree["to_time"]))
95
+ branch_table.add_row("Amount of additions", str(diff_tree["num_added"]))
96
+ branch_table.add_row("Amount of deletions", str(diff_tree["num_removed"]))
97
+ branch_table.add_row("Amount of updates", str(diff_tree["num_updated"]))
98
+ branch_table.add_row("Amount of conflicts", str(diff_tree["num_conflicts"]))
99
+ else:
100
+ branch_table.add_row("Diff last updated", "No diff available")
101
+ branch_table.add_row("Amount of additions", "-")
102
+ branch_table.add_row("Amount of deletions", "-")
103
+ branch_table.add_row("Amount of updates", "-")
104
+ branch_table.add_row("Amount of conflicts", "-")
105
+
106
+ return branch_table
107
+
108
+
109
+ def generate_proposed_change_tables(proposed_changes: list[CoreProposedChange]) -> list[Table]:
110
+ proposed_change_tables: list[Table] = []
111
+
112
+ for pc in proposed_changes:
113
+ # Create proposal table
114
+ proposed_change_table = Table(show_header=False, box=None)
115
+ proposed_change_table.add_column(justify="left")
116
+ proposed_change_table.add_column(justify="right")
117
+
118
+ # Extract data from node
119
+ proposed_change_table.add_row("Name", pc.name.value)
120
+ proposed_change_table.add_row("State", str(pc.state.value))
121
+ proposed_change_table.add_row("Is draft", "Yes" if pc.is_draft.value else "No")
122
+ proposed_change_table.add_row("Created by", pc.created_by.peer.name.value) # type: ignore[union-attr]
123
+ proposed_change_table.add_row("Created at", format_timestamp(str(pc.created_by.updated_at)))
124
+ proposed_change_table.add_row("Approvals", str(len(pc.approved_by.peers)))
125
+ proposed_change_table.add_row("Rejections", str(len(pc.rejected_by.peers)))
126
+
127
+ proposed_change_tables.append(proposed_change_table)
128
+
129
+ return proposed_change_tables
130
+
131
+
21
132
  @app.callback()
22
133
  def callback() -> None:
23
134
  """
@@ -49,7 +160,7 @@ async def list_branch(_: str = CONFIG_PARAM) -> None:
49
160
  table.add_column("Status")
50
161
 
51
162
  # identify the default branch and always print it first
52
- default_branch = [branch for branch in branches.values() if branch.is_default][0]
163
+ default_branch = next(branch for branch in branches.values() if branch.is_default)
53
164
  table.add_row(
54
165
  default_branch.name,
55
166
  default_branch.description or " - ",
@@ -143,3 +254,66 @@ async def validate(branch_name: str, _: str = CONFIG_PARAM) -> None:
143
254
  client = initialize_client()
144
255
  await client.branch.validate(branch_name=branch_name)
145
256
  console.print(f"Branch '{branch_name}' is valid.")
257
+
258
+
259
+ @app.command()
260
+ @catch_exception(console=console)
261
+ async def report(
262
+ branch_name: str = typer.Argument(..., help="Branch name to generate report for"),
263
+ update_diff: bool = typer.Option(False, "--update-diff", help="Update diff before generating report"),
264
+ _: str = CONFIG_PARAM,
265
+ ) -> None:
266
+ """Generate branch cleanup status report."""
267
+
268
+ client = initialize_client()
269
+
270
+ # Fetch branch metadata first (needed for diff creation)
271
+ branch = await client.branch.get(branch_name=branch_name)
272
+
273
+ if branch.is_default:
274
+ console.print("[red]Cannot create a report for the default branch!")
275
+ sys.exit(1)
276
+
277
+ # Update diff if requested
278
+ if update_diff:
279
+ console.print("Updating diff...")
280
+ # Create diff from branch creation to now
281
+ from_time = datetime.fromisoformat(branch.branched_from.replace("Z", "+00:00"))
282
+ to_time = datetime.now(timezone.utc)
283
+ await client.create_diff(
284
+ branch=branch_name,
285
+ name=f"report-{branch_name}",
286
+ from_time=from_time,
287
+ to_time=to_time,
288
+ )
289
+ console.print("Diff updated\n")
290
+
291
+ diff_tree = await client.get_diff_tree(branch=branch_name)
292
+
293
+ git_files_changed = await check_git_files_changed(client, branch=branch_name)
294
+
295
+ proposed_changes = await client.filters(
296
+ kind=CoreProposedChange, # type: ignore[type-abstract]
297
+ source_branch__value=branch_name,
298
+ include=["created_by"],
299
+ prefetch_relationships=True,
300
+ property=True,
301
+ )
302
+
303
+ branch_table = generate_branch_report_table(branch=branch, diff_tree=diff_tree, git_files_changed=git_files_changed)
304
+ proposed_change_tables = generate_proposed_change_tables(proposed_changes=proposed_changes)
305
+
306
+ console.print()
307
+ console.print(f"[bold]Branch: {branch_name}[/bold]")
308
+
309
+ console.print(branch_table)
310
+ console.print()
311
+
312
+ if not proposed_changes:
313
+ console.print("No proposed changes for this branch")
314
+ console.print()
315
+
316
+ for proposed_change, proposed_change_table in zip(proposed_changes, proposed_change_tables, strict=True):
317
+ console.print(f"Proposed change: {proposed_change.name.value}")
318
+ console.print(proposed_change_table)
319
+ console.print()
infrahub_sdk/ctl/check.py CHANGED
@@ -5,7 +5,7 @@ import sys
5
5
  from asyncio import run as aiorun
6
6
  from dataclasses import dataclass
7
7
  from pathlib import Path
8
- from typing import TYPE_CHECKING, Optional
8
+ from typing import TYPE_CHECKING
9
9
 
10
10
  import typer
11
11
  from rich.console import Console
@@ -49,8 +49,8 @@ def run(
49
49
  format_json: bool,
50
50
  list_available: bool,
51
51
  variables: dict[str, str],
52
- name: Optional[str] = None,
53
- branch: Optional[str] = None,
52
+ name: str | None = None,
53
+ branch: str | None = None,
54
54
  ) -> None:
55
55
  """Locate and execute all checks under the defined path."""
56
56
 
infrahub_sdk/ctl/cli.py CHANGED
@@ -4,8 +4,8 @@ try:
4
4
  from .cli_commands import app
5
5
  except ImportError as exc:
6
6
  sys.exit(
7
- f"Module {exc.name} is not available, install the 'ctl' extra of the infrahub-sdk package, `pip install 'infrahub-sdk[ctl]'` or enable the "
8
- "Poetry shell and run `poetry install --extras ctl`."
7
+ f"Module {exc.name} is not available, install the 'ctl' extra of the infrahub-sdk package, "
8
+ f"`pip install 'infrahub-sdk[ctl]'` or run `uv sync --extra ctl`."
9
9
  )
10
10
 
11
11
  __all__ = ["app"]
@@ -6,8 +6,9 @@ import importlib
6
6
  import logging
7
7
  import platform
8
8
  import sys
9
+ from collections.abc import Callable
9
10
  from pathlib import Path
10
- from typing import TYPE_CHECKING, Any, Callable, Optional
11
+ from typing import TYPE_CHECKING, Any
11
12
 
12
13
  import typer
13
14
  import ujson
@@ -77,13 +78,13 @@ console = Console()
77
78
  @catch_exception(console=console)
78
79
  def check(
79
80
  check_name: str = typer.Argument(default="", help="Name of the Python check"),
80
- branch: Optional[str] = None,
81
+ branch: str | None = None,
81
82
  path: str = typer.Option(".", help="Root directory"),
82
83
  debug: bool = False,
83
84
  format_json: bool = False,
84
85
  _: str = CONFIG_PARAM,
85
86
  list_available: bool = typer.Option(False, "--list", help="Show available Python checks"),
86
- variables: Optional[list[str]] = typer.Argument(
87
+ variables: list[str] | None = typer.Argument(
87
88
  None, help="Variables to pass along with the query. Format key=value key=value."
88
89
  ),
89
90
  ) -> None:
@@ -105,12 +106,12 @@ def check(
105
106
  @catch_exception(console=console)
106
107
  async def generator(
107
108
  generator_name: str = typer.Argument(default="", help="Name of the Generator"),
108
- branch: Optional[str] = None,
109
+ branch: str | None = None,
109
110
  path: str = typer.Option(".", help="Root directory"),
110
111
  debug: bool = False,
111
112
  _: str = CONFIG_PARAM,
112
113
  list_available: bool = typer.Option(False, "--list", help="Show available Generators"),
113
- variables: Optional[list[str]] = typer.Argument(
114
+ variables: list[str] | None = typer.Argument(
114
115
  None, help="Variables to pass along with the query. Format key=value key=value."
115
116
  ),
116
117
  ) -> None:
@@ -133,13 +134,13 @@ async def run(
133
134
  debug: bool = False,
134
135
  _: str = CONFIG_PARAM,
135
136
  branch: str = typer.Option(None, help="Branch on which to run the script."),
136
- concurrent: Optional[int] = typer.Option(
137
+ concurrent: int | None = typer.Option(
137
138
  None,
138
139
  help="Maximum number of requests to execute at the same time.",
139
140
  envvar="INFRAHUB_MAX_CONCURRENT_EXECUTION",
140
141
  ),
141
142
  timeout: int = typer.Option(60, help="Timeout in sec", envvar="INFRAHUB_TIMEOUT"),
142
- variables: Optional[list[str]] = typer.Argument(
143
+ variables: list[str] | None = typer.Argument(
143
144
  None, help="Variables to pass along with the query. Format key=value key=value."
144
145
  ),
145
146
  ) -> None:
@@ -250,7 +251,7 @@ async def _run_transform(
250
251
  @catch_exception(console=console)
251
252
  async def render(
252
253
  transform_name: str = typer.Argument(default="", help="Name of the Python transformation", show_default=False),
253
- variables: Optional[list[str]] = typer.Argument(
254
+ variables: list[str] | None = typer.Argument(
254
255
  None, help="Variables to pass along with the query. Format key=value key=value."
255
256
  ),
256
257
  branch: str = typer.Option(None, help="Branch on which to render the transform."),
@@ -300,7 +301,7 @@ async def render(
300
301
  @catch_exception(console=console)
301
302
  def transform(
302
303
  transform_name: str = typer.Argument(default="", help="Name of the Python transformation", show_default=False),
303
- variables: Optional[list[str]] = typer.Argument(
304
+ variables: list[str] | None = typer.Argument(
304
305
  None, help="Variables to pass along with the query. Format key=value key=value."
305
306
  ),
306
307
  branch: str = typer.Option(None, help="Branch on which to run the transformation"),
@@ -1,7 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from pathlib import Path
4
- from typing import TYPE_CHECKING, Optional
4
+ from typing import TYPE_CHECKING
5
5
 
6
6
  import typer
7
7
  from rich.console import Console
@@ -22,7 +22,7 @@ async def run(
22
22
  debug: bool,
23
23
  list_available: bool,
24
24
  branch: str | None = None,
25
- variables: Optional[list[str]] = None,
25
+ variables: list[str] | None = None,
26
26
  ) -> None:
27
27
  init_logging(debug=debug)
28
28
  repository_config = get_repository_config(find_repository_config_file())
@@ -3,7 +3,6 @@ from __future__ import annotations
3
3
  import ast
4
4
  from collections import defaultdict
5
5
  from pathlib import Path
6
- from typing import Optional
7
6
 
8
7
  import typer
9
8
  from ariadne_codegen.client_generators.package import PackageGenerator, get_package_generator
@@ -108,14 +107,14 @@ async def export_schema(
108
107
  schema_text = await client.schema.get_graphql_schema()
109
108
 
110
109
  destination.parent.mkdir(parents=True, exist_ok=True)
111
- destination.write_text(schema_text)
110
+ destination.write_text(schema_text, encoding="utf-8")
112
111
  console.print(f"[green]Schema exported to {destination}")
113
112
 
114
113
 
115
114
  @app.command()
116
115
  @catch_exception(console=console)
117
116
  async def generate_return_types(
118
- query: Optional[Path] = typer.Argument(
117
+ query: Path | None = typer.Argument(
119
118
  None, help="Location of the GraphQL query file(s). Defaults to current directory if not specified."
120
119
  ),
121
120
  schema: Path = typer.Option("schema.graphql", help="Path to the GraphQL schema file."),
@@ -180,5 +179,5 @@ async def generate_return_types(
180
179
 
181
180
  generate_result_types(directory=directory, package=package_generator, fragment=module_fragment)
182
181
 
183
- for file_name in package_generator._result_types_files.keys():
182
+ for file_name in package_generator._result_types_files:
184
183
  console.print(f"[green]Generated {file_name} in {directory}")
@@ -2,7 +2,6 @@ from __future__ import annotations
2
2
 
3
3
  from asyncio import run as aiorun
4
4
  from pathlib import Path
5
- from typing import Optional
6
5
 
7
6
  import typer
8
7
  from rich.console import Console
@@ -16,7 +15,7 @@ from .parameters import CONFIG_PARAM
16
15
 
17
16
  def local_directory() -> Path:
18
17
  # We use a function here to avoid failure when generating the documentation due to directory name
19
- return Path().resolve()
18
+ return Path.cwd()
20
19
 
21
20
 
22
21
  def load(
@@ -27,7 +26,7 @@ def load(
27
26
  quiet: bool = typer.Option(False, help="No console output"),
28
27
  _: str = CONFIG_PARAM,
29
28
  branch: str = typer.Option(None, help="Branch from which to export"),
30
- concurrent: Optional[int] = typer.Option(
29
+ concurrent: int | None = typer.Option(
31
30
  None,
32
31
  help="Maximum number of requests to execute at the same time.",
33
32
  envvar="INFRAHUB_MAX_CONCURRENT_EXECUTION",
@@ -2,7 +2,6 @@ from __future__ import annotations
2
2
 
3
3
  import asyncio
4
4
  from pathlib import Path
5
- from typing import Optional
6
5
 
7
6
  import typer
8
7
  import yaml
@@ -109,7 +108,7 @@ async def add(
109
108
  name: str,
110
109
  location: str,
111
110
  description: str = "",
112
- username: Optional[str] = None,
111
+ username: str | None = None,
113
112
  password: str = "",
114
113
  ref: str = "",
115
114
  read_only: bool = False,
@@ -155,7 +154,7 @@ async def add(
155
154
 
156
155
  @app.command()
157
156
  async def list(
158
- branch: Optional[str] = typer.Option(None, help="Branch on which to list repositories."),
157
+ branch: str | None = typer.Option(None, help="Branch on which to list repositories."),
159
158
  debug: bool = False,
160
159
  _: str = CONFIG_PARAM,
161
160
  ) -> None:
@@ -214,12 +213,12 @@ async def init(
214
213
  default="https://github.com/opsmill/infrahub-template.git",
215
214
  help="Template to use for the new repository. Can be a local path or a git repository URL.",
216
215
  ),
217
- data: Optional[Path] = typer.Option(default=None, help="Path to YAML file containing answers to CLI prompt."),
218
- vcs_ref: Optional[str] = typer.Option(
216
+ data: Path | None = typer.Option(default=None, help="Path to YAML file containing answers to CLI prompt."),
217
+ vcs_ref: str | None = typer.Option(
219
218
  default="HEAD",
220
219
  help="VCS reference to use for the template. Defaults to HEAD.",
221
220
  ),
222
- trust: Optional[bool] = typer.Option(
221
+ trust: bool | None = typer.Option(
223
222
  default=False,
224
223
  help="Trust the template repository. If set, the template will be cloned without verification.",
225
224
  ),