infrahub-server 1.6.0b0__py3-none-any.whl → 1.6.1__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 (88) hide show
  1. infrahub/api/oauth2.py +33 -6
  2. infrahub/api/oidc.py +36 -6
  3. infrahub/auth.py +11 -0
  4. infrahub/auth_pkce.py +41 -0
  5. infrahub/config.py +9 -3
  6. infrahub/core/branch/models.py +3 -2
  7. infrahub/core/changelog/models.py +2 -2
  8. infrahub/core/constants/__init__.py +1 -0
  9. infrahub/core/graph/__init__.py +1 -1
  10. infrahub/core/integrity/object_conflict/conflict_recorder.py +1 -1
  11. infrahub/core/manager.py +36 -31
  12. infrahub/core/migrations/graph/__init__.py +2 -0
  13. infrahub/core/migrations/graph/m047_backfill_or_null_display_label.py +606 -0
  14. infrahub/core/models.py +5 -6
  15. infrahub/core/node/__init__.py +16 -13
  16. infrahub/core/node/create.py +36 -8
  17. infrahub/core/node/proposed_change.py +5 -3
  18. infrahub/core/node/standard.py +1 -1
  19. infrahub/core/protocols.py +1 -7
  20. infrahub/core/query/attribute.py +1 -1
  21. infrahub/core/query/node.py +9 -5
  22. infrahub/core/relationship/model.py +21 -4
  23. infrahub/core/schema/generic_schema.py +1 -1
  24. infrahub/core/schema/manager.py +8 -3
  25. infrahub/core/schema/schema_branch.py +35 -16
  26. infrahub/core/validators/attribute/choices.py +2 -2
  27. infrahub/core/validators/determiner.py +3 -6
  28. infrahub/database/__init__.py +1 -1
  29. infrahub/git/base.py +2 -3
  30. infrahub/git/models.py +13 -0
  31. infrahub/git/tasks.py +23 -19
  32. infrahub/git/utils.py +16 -9
  33. infrahub/graphql/app.py +6 -6
  34. infrahub/graphql/loaders/peers.py +6 -0
  35. infrahub/graphql/mutations/action.py +15 -7
  36. infrahub/graphql/mutations/hfid.py +1 -1
  37. infrahub/graphql/mutations/profile.py +8 -1
  38. infrahub/graphql/mutations/repository.py +3 -3
  39. infrahub/graphql/mutations/schema.py +4 -4
  40. infrahub/graphql/mutations/webhook.py +2 -2
  41. infrahub/graphql/queries/resource_manager.py +2 -3
  42. infrahub/graphql/queries/search.py +2 -3
  43. infrahub/graphql/resolvers/ipam.py +20 -0
  44. infrahub/graphql/resolvers/many_relationship.py +12 -11
  45. infrahub/graphql/resolvers/resolver.py +6 -2
  46. infrahub/graphql/resolvers/single_relationship.py +1 -11
  47. infrahub/log.py +1 -1
  48. infrahub/message_bus/messages/__init__.py +0 -12
  49. infrahub/profiles/node_applier.py +9 -0
  50. infrahub/proposed_change/branch_diff.py +1 -1
  51. infrahub/proposed_change/tasks.py +1 -1
  52. infrahub/repositories/create_repository.py +3 -3
  53. infrahub/task_manager/models.py +1 -1
  54. infrahub/task_manager/task.py +5 -5
  55. infrahub/trigger/setup.py +6 -9
  56. infrahub/utils.py +18 -0
  57. infrahub/validators/tasks.py +1 -1
  58. infrahub/workers/infrahub_async.py +7 -6
  59. infrahub_sdk/client.py +113 -1
  60. infrahub_sdk/ctl/AGENTS.md +67 -0
  61. infrahub_sdk/ctl/branch.py +175 -1
  62. infrahub_sdk/ctl/check.py +3 -3
  63. infrahub_sdk/ctl/cli_commands.py +9 -9
  64. infrahub_sdk/ctl/generator.py +2 -2
  65. infrahub_sdk/ctl/graphql.py +1 -2
  66. infrahub_sdk/ctl/importer.py +1 -2
  67. infrahub_sdk/ctl/repository.py +6 -49
  68. infrahub_sdk/ctl/task.py +2 -4
  69. infrahub_sdk/ctl/utils.py +2 -2
  70. infrahub_sdk/ctl/validate.py +1 -2
  71. infrahub_sdk/diff.py +80 -3
  72. infrahub_sdk/graphql/constants.py +14 -1
  73. infrahub_sdk/graphql/renderers.py +5 -1
  74. infrahub_sdk/node/attribute.py +0 -1
  75. infrahub_sdk/node/constants.py +3 -1
  76. infrahub_sdk/node/node.py +303 -3
  77. infrahub_sdk/node/related_node.py +1 -2
  78. infrahub_sdk/node/relationship.py +1 -2
  79. infrahub_sdk/protocols_base.py +0 -1
  80. infrahub_sdk/pytest_plugin/AGENTS.md +67 -0
  81. infrahub_sdk/schema/__init__.py +0 -3
  82. infrahub_sdk/timestamp.py +7 -7
  83. {infrahub_server-1.6.0b0.dist-info → infrahub_server-1.6.1.dist-info}/METADATA +2 -3
  84. {infrahub_server-1.6.0b0.dist-info → infrahub_server-1.6.1.dist-info}/RECORD +88 -84
  85. {infrahub_server-1.6.0b0.dist-info → infrahub_server-1.6.1.dist-info}/WHEEL +1 -1
  86. infrahub_testcontainers/container.py +2 -2
  87. {infrahub_server-1.6.0b0.dist-info → infrahub_server-1.6.1.dist-info}/entry_points.txt +0 -0
  88. {infrahub_server-1.6.0b0.dist-info → infrahub_server-1.6.1.dist-info}/licenses/LICENSE.txt +0 -0
@@ -9,6 +9,15 @@ from .queries.get_profile_data import GetProfileDataQuery, ProfileData
9
9
 
10
10
 
11
11
  class NodeProfilesApplier:
12
+ """Applies profile values to nodes and templates.
13
+
14
+ Profile values take precedence over both default values and template-sourced values.
15
+ When a template has profiles assigned:
16
+ 1. Profile values are applied to the template itself
17
+ 2. Nodes created from that template inherit the profile values (not the template's own values)
18
+ 3. Profile priority determines which profile wins when multiple profiles set the same attribute
19
+ """
20
+
12
21
  def __init__(self, db: InfrahubDatabase, branch: Branch):
13
22
  self.db = db
14
23
  self.branch = branch
@@ -64,4 +64,4 @@ async def get_diff_summary_cache(pipeline_id: UUID) -> list[NodeDiff]:
64
64
  if not summary_payload:
65
65
  raise ResourceNotFoundError(message=f"Diff summary for pipeline {pipeline_id} was not found in the cache")
66
66
 
67
- return cast(list["NodeDiff"], json.loads(summary_payload))
67
+ return cast("list[NodeDiff]", json.loads(summary_payload))
@@ -480,7 +480,7 @@ async def _get_proposed_change_schema_integrity_constraints(
480
480
  DiffElementType.RELATIONSHIP_ONE.value.lower(),
481
481
  ):
482
482
  field_summary.relationship_names.add(element_name)
483
- elif element_type.lower() in (DiffElementType.ATTRIBUTE.value.lower(),):
483
+ elif element_type.lower() == DiffElementType.ATTRIBUTE.value.lower():
484
484
  field_summary.attribute_names.add(element_name)
485
485
 
486
486
  determiner = ConstraintValidatorDeterminer(schema_branch=schema)
@@ -4,7 +4,6 @@ from typing import TYPE_CHECKING, cast
4
4
 
5
5
  from infrahub.core.constants import RepositoryInternalStatus
6
6
  from infrahub.core.constants.infrahubkind import READONLYREPOSITORY, REPOSITORY
7
- from infrahub.core.protocols import CoreGenericRepository, CoreReadOnlyRepository, CoreRepository
8
7
  from infrahub.exceptions import ValidationError
9
8
  from infrahub.git.models import GitRepositoryAdd, GitRepositoryAddReadOnly
10
9
  from infrahub.log import get_logger
@@ -16,6 +15,7 @@ if TYPE_CHECKING:
16
15
  from infrahub.auth import AccountSession
17
16
  from infrahub.context import InfrahubContext
18
17
  from infrahub.core.branch import Branch
18
+ from infrahub.core.protocols import CoreGenericRepository, CoreReadOnlyRepository, CoreRepository
19
19
  from infrahub.database import InfrahubDatabase
20
20
  from infrahub.services import InfrahubServices
21
21
 
@@ -74,7 +74,7 @@ class RepositoryFinalizer:
74
74
  authenticated_user = self.account_session.account_id
75
75
 
76
76
  if obj.get_kind() == READONLYREPOSITORY:
77
- obj = cast(CoreReadOnlyRepository, obj)
77
+ obj = cast("CoreReadOnlyRepository", obj)
78
78
  model = GitRepositoryAddReadOnly(
79
79
  repository_id=obj.id,
80
80
  repository_name=obj.name.value,
@@ -92,7 +92,7 @@ class RepositoryFinalizer:
92
92
  )
93
93
 
94
94
  elif obj.get_kind() == REPOSITORY:
95
- obj = cast(CoreRepository, obj)
95
+ obj = cast("CoreRepository", obj)
96
96
  git_repo_add_model = GitRepositoryAdd(
97
97
  repository_id=obj.id,
98
98
  repository_name=obj.name.value,
@@ -74,7 +74,7 @@ class FlowLogs(BaseModel):
74
74
  "node": {
75
75
  "message": log.message,
76
76
  "severity": LOG_LEVEL_MAPPING.get(log.level, "error"),
77
- "timestamp": log.timestamp.to_iso8601_string(),
77
+ "timestamp": log.timestamp.isoformat(),
78
78
  }
79
79
  }
80
80
  for log in self.logs[flow_id]
@@ -151,7 +151,7 @@ class PrefectTask:
151
151
  remaining -= nb_fetched
152
152
 
153
153
  for flow_log in all_logs:
154
- if flow_log.flow_run_id and flow_log.message not in ["Finished in state Completed()"]:
154
+ if flow_log.flow_run_id and flow_log.message != "Finished in state Completed()":
155
155
  logs_flow.logs[flow_log.flow_run_id].append(flow_log)
156
156
 
157
157
  return logs_flow
@@ -325,13 +325,13 @@ class PrefectTask:
325
325
  "parameters": flow.parameters,
326
326
  "branch": await cls._extract_branch_name(flow=flow),
327
327
  "tags": flow.tags,
328
- "workflow": workflow_names.get(flow.flow_id, None),
328
+ "workflow": workflow_names.get(flow.flow_id),
329
329
  "related_node": related_node.id if related_node else None,
330
330
  "related_node_kind": related_node.kind if related_node else None,
331
331
  "related_nodes": related_nodes_info.get_related_nodes_as_dict(flow_id=flow.id),
332
- "created_at": flow.created.to_iso8601_string(), # type: ignore
333
- "updated_at": flow.updated.to_iso8601_string(), # type: ignore
334
- "start_time": flow.start_time.to_iso8601_string() if flow.start_time else None,
332
+ "created_at": flow.created.isoformat() if flow.created else None,
333
+ "updated_at": flow.updated.isoformat() if flow.updated else None,
334
+ "start_time": flow.start_time.isoformat() if flow.start_time else None,
335
335
  "id": flow.id,
336
336
  "logs": {"edges": logs, "count": len(logs)},
337
337
  }
infrahub/trigger/setup.py CHANGED
@@ -122,7 +122,7 @@ async def setup_triggers(
122
122
  actions=[action.get_prefect(mapping=deployments_mapping) for action in trigger.actions],
123
123
  )
124
124
 
125
- existing_automation = existing_automations.get(trigger.generate_name(), None)
125
+ existing_automation = existing_automations.get(trigger.generate_name())
126
126
 
127
127
  if existing_automation:
128
128
  trigger_comparison = compare_automations(
@@ -171,19 +171,16 @@ async def gather_all_automations(client: PrefectClient) -> list[Automation]:
171
171
  retrieves them all by paginating through the results. The default within Prefect is 200 items,
172
172
  and client.read_automations() doesn't support pagination parameters.
173
173
  """
174
- automation_count_response = await client.request("POST", "/automations/count")
175
- automation_count_response.raise_for_status()
176
- automation_count: int = automation_count_response.json()
177
174
  offset = 0
178
175
  limit = 200
179
- missing_automations = True
180
176
  automations: list[Automation] = []
181
- while missing_automations:
177
+ while True:
182
178
  response = await client.request("POST", "/automations/filter", json={"limit": limit, "offset": offset})
183
179
  response.raise_for_status()
184
- automations.extend(Automation.model_validate_list(response.json()))
185
- if len(automations) >= automation_count:
186
- missing_automations = False
180
+ batch = Automation.model_validate_list(response.json())
181
+ automations.extend(batch)
182
+ if len(batch) < limit:
183
+ break
187
184
  offset += limit
188
185
 
189
186
  return automations
infrahub/utils.py CHANGED
@@ -83,3 +83,21 @@ def get_all_subclasses[AnyClass: type](cls: AnyClass) -> list[AnyClass]:
83
83
  subclasses.append(subclass)
84
84
  subclasses.extend(get_all_subclasses(subclass))
85
85
  return subclasses
86
+
87
+
88
+ def has_any_key(data: dict[str, Any], keys: list[str]) -> bool:
89
+ """Recursively check if any of the specified keys exist in the dictionary at any level.
90
+
91
+ Args:
92
+ data: The dictionary to search through
93
+ keys: List of key names to search for
94
+
95
+ Returns:
96
+ True if any of the keys are found at any level of the dictionary, False otherwise
97
+ """
98
+ for key, value in data.items():
99
+ if key in keys:
100
+ return True
101
+ if isinstance(value, dict) and has_any_key(data=value, keys=keys):
102
+ return True
103
+ return False
@@ -26,7 +26,7 @@ async def start_validator[ValidatorType: CoreValidator](
26
26
  validator.started_at.value = ""
27
27
  validator.completed_at.value = ""
28
28
  await validator.save()
29
- validator = cast(ValidatorType, validator)
29
+ validator = cast("ValidatorType", validator)
30
30
  else:
31
31
  data["proposed_change"] = proposed_change
32
32
  validator = await client.create(kind=validator_type, data=data)
@@ -1,4 +1,5 @@
1
1
  import asyncio
2
+ import contextlib
2
3
  import logging
3
4
  import os
4
5
  from pathlib import Path
@@ -107,7 +108,7 @@ class InfrahubWorkerAsync(BaseWorker):
107
108
 
108
109
  # Start metric endpoint
109
110
  if metric_port is None or metric_port != 0:
110
- metric_port = metric_port or int(os.environ.get("INFRAHUB_METRICS_PORT", 8000))
111
+ metric_port = metric_port or int(os.environ.get("INFRAHUB_METRICS_PORT", "8000"))
111
112
  self._logger.info(f"Starting metric endpoint on port {metric_port}")
112
113
  start_http_server(metric_port)
113
114
 
@@ -212,18 +213,18 @@ class InfrahubWorkerAsync(BaseWorker):
212
213
  global_config_file = config.SETTINGS.git.global_config_file
213
214
  if not os.getenv("GIT_CONFIG_GLOBAL") and global_config_file:
214
215
  config_dir = Path(global_config_file).parent
215
- try:
216
+ with contextlib.suppress(FileExistsError):
216
217
  config_dir.mkdir(exist_ok=True, parents=True)
217
- except FileExistsError:
218
- pass
219
218
  os.environ["GIT_CONFIG_GLOBAL"] = global_config_file
220
219
  self._logger.info(f"Set git config file to {global_config_file}")
221
220
 
222
221
  await self._run_git_config_global(config.SETTINGS.git.user_name, setting_name="user.name")
223
222
  await self._run_git_config_global(config.SETTINGS.git.user_email, setting_name="user.email")
224
- await self._run_git_config_global("'*'", "--replace-all", setting_name="safe.directory")
223
+ await self._run_git_config_global("*", "--replace-all", setting_name="safe.directory")
225
224
  await self._run_git_config_global("true", setting_name="credential.usehttppath")
226
- await self._run_git_config_global(config.SETTINGS.dev.git_credential_helper, setting_name="credential.helper")
225
+ await self._run_git_config_global(
226
+ f"/usr/bin/env {config.SETTINGS.dev.git_credential_helper}", setting_name="credential.helper"
227
+ )
227
228
 
228
229
  async def _run_git_config_global(self, *args: str, setting_name: str) -> None:
229
230
  proc = await asyncio.create_subprocess_exec(
infrahub_sdk/client.py CHANGED
@@ -34,7 +34,7 @@ from .config import Config
34
34
  from .constants import InfrahubClientMode
35
35
  from .convert_object_type import CONVERT_OBJECT_MUTATION, ConversionFieldInput
36
36
  from .data import RepositoryBranchInfo, RepositoryData
37
- 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
38
38
  from .exceptions import (
39
39
  AuthenticationError,
40
40
  Error,
@@ -1282,6 +1282,62 @@ class InfrahubClient(BaseClient):
1282
1282
 
1283
1283
  return node_diffs
1284
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
+
1285
1341
  @overload
1286
1342
  async def allocate_next_ip_address(
1287
1343
  self,
@@ -2520,6 +2576,62 @@ class InfrahubClientSync(BaseClient):
2520
2576
 
2521
2577
  return node_diffs
2522
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
+
2523
2635
  @overload
2524
2636
  def allocate_next_ip_address(
2525
2637
  self,
@@ -0,0 +1,67 @@
1
+ # infrahub_sdk/ctl/AGENTS.md
2
+
3
+ CLI tool (`infrahubctl`) built with Typer/AsyncTyper.
4
+
5
+ ## Command Pattern
6
+
7
+ ```python
8
+ from rich.console import Console
9
+ from ..async_typer import AsyncTyper
10
+ from ..ctl.client import initialize_client
11
+ from ..ctl.utils import catch_exception
12
+ from .parameters import CONFIG_PARAM
13
+
14
+ console = Console()
15
+ app = AsyncTyper()
16
+
17
+ @app.command(name="my-command")
18
+ @catch_exception(console=console)
19
+ async def my_command(
20
+ path: str = typer.Option(".", help="Path to file"),
21
+ branch: Optional[str] = None,
22
+ _: str = CONFIG_PARAM, # Always include, even if unused
23
+ ):
24
+ client = initialize_client(branch=branch)
25
+ # implementation using Rich for output
26
+ console.print(Panel("Result", title="Success"))
27
+ ```
28
+
29
+ ## File Organization
30
+
31
+ ```text
32
+ infrahub_sdk/ctl/
33
+ ├── cli_commands.py # Entry point, registers subcommands
34
+ ├── client.py # initialize_client(), initialize_client_sync()
35
+ ├── utils.py # catch_exception decorator, parse_cli_vars
36
+ ├── parameters.py # CONFIG_PARAM and shared parameters
37
+ ├── branch.py # Branch subcommands
38
+ ├── schema.py # Schema subcommands
39
+ └── object.py # Object subcommands
40
+ ```
41
+
42
+ ## Registering Commands
43
+
44
+ ```python
45
+ # In cli_commands.py
46
+ from ..ctl.branch import app as branch_app
47
+ app.add_typer(branch_app, name="branch")
48
+
49
+ # Or for top-level commands
50
+ from .exporter import dump
51
+ app.command(name="dump")(dump)
52
+ ```
53
+
54
+ ## Boundaries
55
+
56
+ ✅ **Always**
57
+
58
+ - Use `@catch_exception(console=console)` decorator
59
+ - Include `CONFIG_PARAM` in all commands
60
+ - Use `initialize_client()` for client creation
61
+ - Use Rich for output (tables, panels, console.print)
62
+
63
+ 🚫 **Never**
64
+
65
+ - Use plain `print()` statements
66
+ - Instantiate `InfrahubClient` directly (use `initialize_client`)
67
+ - Forget error handling decorator
@@ -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
  """
@@ -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