infrahub-server 1.4.12__py3-none-any.whl → 1.5.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 (234) hide show
  1. infrahub/actions/tasks.py +208 -16
  2. infrahub/api/artifact.py +3 -0
  3. infrahub/api/diff/diff.py +1 -1
  4. infrahub/api/internal.py +2 -0
  5. infrahub/api/query.py +2 -0
  6. infrahub/api/schema.py +27 -3
  7. infrahub/auth.py +5 -5
  8. infrahub/cli/__init__.py +2 -0
  9. infrahub/cli/db.py +160 -157
  10. infrahub/cli/dev.py +118 -0
  11. infrahub/cli/tasks.py +46 -0
  12. infrahub/cli/upgrade.py +56 -9
  13. infrahub/computed_attribute/tasks.py +19 -7
  14. infrahub/config.py +7 -2
  15. infrahub/core/attribute.py +35 -24
  16. infrahub/core/branch/enums.py +1 -1
  17. infrahub/core/branch/models.py +9 -5
  18. infrahub/core/branch/needs_rebase_status.py +11 -0
  19. infrahub/core/branch/tasks.py +72 -10
  20. infrahub/core/changelog/models.py +2 -10
  21. infrahub/core/constants/__init__.py +4 -0
  22. infrahub/core/constants/infrahubkind.py +1 -0
  23. infrahub/core/convert_object_type/object_conversion.py +201 -0
  24. infrahub/core/convert_object_type/repository_conversion.py +89 -0
  25. infrahub/core/convert_object_type/schema_mapping.py +27 -3
  26. infrahub/core/diff/calculator.py +2 -2
  27. infrahub/core/diff/model/path.py +4 -0
  28. infrahub/core/diff/payload_builder.py +1 -1
  29. infrahub/core/diff/query/artifact.py +1 -0
  30. infrahub/core/diff/query/delete_query.py +9 -5
  31. infrahub/core/diff/query/field_summary.py +1 -0
  32. infrahub/core/diff/query/merge.py +39 -23
  33. infrahub/core/graph/__init__.py +1 -1
  34. infrahub/core/initialization.py +7 -4
  35. infrahub/core/manager.py +3 -81
  36. infrahub/core/migrations/__init__.py +3 -0
  37. infrahub/core/migrations/exceptions.py +4 -0
  38. infrahub/core/migrations/graph/__init__.py +13 -10
  39. infrahub/core/migrations/graph/load_schema_branch.py +21 -0
  40. infrahub/core/migrations/graph/m013_convert_git_password_credential.py +1 -1
  41. infrahub/core/migrations/graph/m037_index_attr_vals.py +11 -30
  42. infrahub/core/migrations/graph/m039_ipam_reconcile.py +9 -7
  43. infrahub/core/migrations/graph/m041_deleted_dup_edges.py +149 -0
  44. infrahub/core/migrations/graph/m042_profile_attrs_in_db.py +147 -0
  45. infrahub/core/migrations/graph/m043_create_hfid_display_label_in_db.py +164 -0
  46. infrahub/core/migrations/graph/m044_backfill_hfid_display_label_in_db.py +864 -0
  47. infrahub/core/migrations/query/__init__.py +7 -8
  48. infrahub/core/migrations/query/attribute_add.py +8 -6
  49. infrahub/core/migrations/query/attribute_remove.py +134 -0
  50. infrahub/core/migrations/runner.py +54 -0
  51. infrahub/core/migrations/schema/attribute_kind_update.py +9 -3
  52. infrahub/core/migrations/schema/attribute_supports_profile.py +90 -0
  53. infrahub/core/migrations/schema/node_attribute_add.py +26 -5
  54. infrahub/core/migrations/schema/node_attribute_remove.py +13 -109
  55. infrahub/core/migrations/schema/node_kind_update.py +2 -1
  56. infrahub/core/migrations/schema/node_remove.py +2 -1
  57. infrahub/core/migrations/schema/placeholder_dummy.py +3 -2
  58. infrahub/core/migrations/shared.py +66 -19
  59. infrahub/core/models.py +2 -2
  60. infrahub/core/node/__init__.py +207 -54
  61. infrahub/core/node/create.py +53 -49
  62. infrahub/core/node/lock_utils.py +124 -0
  63. infrahub/core/node/node_property_attribute.py +230 -0
  64. infrahub/core/node/resource_manager/ip_address_pool.py +2 -1
  65. infrahub/core/node/resource_manager/ip_prefix_pool.py +2 -1
  66. infrahub/core/node/resource_manager/number_pool.py +2 -1
  67. infrahub/core/node/standard.py +1 -1
  68. infrahub/core/property.py +11 -0
  69. infrahub/core/protocols.py +8 -1
  70. infrahub/core/query/attribute.py +82 -15
  71. infrahub/core/query/diff.py +61 -16
  72. infrahub/core/query/ipam.py +16 -4
  73. infrahub/core/query/node.py +92 -212
  74. infrahub/core/query/relationship.py +44 -26
  75. infrahub/core/query/subquery.py +0 -8
  76. infrahub/core/relationship/model.py +69 -24
  77. infrahub/core/schema/__init__.py +56 -0
  78. infrahub/core/schema/attribute_schema.py +4 -2
  79. infrahub/core/schema/basenode_schema.py +42 -2
  80. infrahub/core/schema/definitions/core/__init__.py +2 -0
  81. infrahub/core/schema/definitions/core/check.py +1 -1
  82. infrahub/core/schema/definitions/core/generator.py +2 -0
  83. infrahub/core/schema/definitions/core/group.py +16 -2
  84. infrahub/core/schema/definitions/core/repository.py +7 -0
  85. infrahub/core/schema/definitions/core/transform.py +1 -1
  86. infrahub/core/schema/definitions/internal.py +12 -3
  87. infrahub/core/schema/generated/attribute_schema.py +2 -2
  88. infrahub/core/schema/generated/base_node_schema.py +6 -1
  89. infrahub/core/schema/manager.py +3 -0
  90. infrahub/core/schema/node_schema.py +1 -0
  91. infrahub/core/schema/relationship_schema.py +0 -1
  92. infrahub/core/schema/schema_branch.py +295 -10
  93. infrahub/core/schema/schema_branch_display.py +135 -0
  94. infrahub/core/schema/schema_branch_hfid.py +120 -0
  95. infrahub/core/validators/aggregated_checker.py +1 -1
  96. infrahub/database/graph.py +21 -0
  97. infrahub/display_labels/__init__.py +0 -0
  98. infrahub/display_labels/gather.py +48 -0
  99. infrahub/display_labels/models.py +240 -0
  100. infrahub/display_labels/tasks.py +192 -0
  101. infrahub/display_labels/triggers.py +22 -0
  102. infrahub/events/branch_action.py +27 -1
  103. infrahub/events/group_action.py +1 -1
  104. infrahub/events/node_action.py +1 -1
  105. infrahub/generators/constants.py +7 -0
  106. infrahub/generators/models.py +38 -12
  107. infrahub/generators/tasks.py +34 -16
  108. infrahub/git/base.py +42 -2
  109. infrahub/git/integrator.py +22 -14
  110. infrahub/git/tasks.py +52 -2
  111. infrahub/graphql/analyzer.py +9 -0
  112. infrahub/graphql/api/dependencies.py +2 -4
  113. infrahub/graphql/api/endpoints.py +16 -6
  114. infrahub/graphql/app.py +2 -4
  115. infrahub/graphql/initialization.py +2 -3
  116. infrahub/graphql/manager.py +213 -137
  117. infrahub/graphql/middleware.py +12 -0
  118. infrahub/graphql/mutations/branch.py +16 -0
  119. infrahub/graphql/mutations/computed_attribute.py +110 -3
  120. infrahub/graphql/mutations/convert_object_type.py +44 -13
  121. infrahub/graphql/mutations/display_label.py +118 -0
  122. infrahub/graphql/mutations/generator.py +25 -7
  123. infrahub/graphql/mutations/hfid.py +125 -0
  124. infrahub/graphql/mutations/ipam.py +73 -41
  125. infrahub/graphql/mutations/main.py +61 -178
  126. infrahub/graphql/mutations/profile.py +195 -0
  127. infrahub/graphql/mutations/proposed_change.py +8 -1
  128. infrahub/graphql/mutations/relationship.py +2 -2
  129. infrahub/graphql/mutations/repository.py +22 -83
  130. infrahub/graphql/mutations/resource_manager.py +2 -2
  131. infrahub/graphql/mutations/webhook.py +1 -1
  132. infrahub/graphql/queries/resource_manager.py +1 -1
  133. infrahub/graphql/registry.py +173 -0
  134. infrahub/graphql/resolvers/resolver.py +2 -0
  135. infrahub/graphql/schema.py +8 -1
  136. infrahub/graphql/schema_sort.py +170 -0
  137. infrahub/graphql/types/branch.py +4 -1
  138. infrahub/graphql/types/enums.py +3 -0
  139. infrahub/groups/tasks.py +1 -1
  140. infrahub/hfid/__init__.py +0 -0
  141. infrahub/hfid/gather.py +48 -0
  142. infrahub/hfid/models.py +240 -0
  143. infrahub/hfid/tasks.py +191 -0
  144. infrahub/hfid/triggers.py +22 -0
  145. infrahub/lock.py +119 -42
  146. infrahub/locks/__init__.py +0 -0
  147. infrahub/locks/tasks.py +37 -0
  148. infrahub/message_bus/types.py +1 -0
  149. infrahub/patch/plan_writer.py +2 -2
  150. infrahub/permissions/constants.py +2 -0
  151. infrahub/profiles/__init__.py +0 -0
  152. infrahub/profiles/node_applier.py +101 -0
  153. infrahub/profiles/queries/__init__.py +0 -0
  154. infrahub/profiles/queries/get_profile_data.py +98 -0
  155. infrahub/profiles/tasks.py +63 -0
  156. infrahub/proposed_change/tasks.py +67 -14
  157. infrahub/repositories/__init__.py +0 -0
  158. infrahub/repositories/create_repository.py +113 -0
  159. infrahub/server.py +9 -1
  160. infrahub/services/__init__.py +8 -5
  161. infrahub/services/adapters/http/__init__.py +5 -0
  162. infrahub/services/adapters/workflow/worker.py +14 -3
  163. infrahub/task_manager/event.py +5 -0
  164. infrahub/task_manager/models.py +7 -0
  165. infrahub/task_manager/task.py +73 -0
  166. infrahub/tasks/registry.py +6 -4
  167. infrahub/trigger/catalogue.py +4 -0
  168. infrahub/trigger/models.py +2 -0
  169. infrahub/trigger/setup.py +13 -4
  170. infrahub/trigger/tasks.py +6 -0
  171. infrahub/webhook/models.py +1 -1
  172. infrahub/workers/dependencies.py +3 -1
  173. infrahub/workers/infrahub_async.py +10 -2
  174. infrahub/workflows/catalogue.py +118 -3
  175. infrahub/workflows/initialization.py +21 -0
  176. infrahub/workflows/models.py +17 -2
  177. infrahub/workflows/utils.py +2 -1
  178. infrahub_sdk/branch.py +17 -8
  179. infrahub_sdk/checks.py +1 -1
  180. infrahub_sdk/client.py +376 -95
  181. infrahub_sdk/config.py +29 -2
  182. infrahub_sdk/convert_object_type.py +61 -0
  183. infrahub_sdk/ctl/branch.py +3 -0
  184. infrahub_sdk/ctl/check.py +2 -3
  185. infrahub_sdk/ctl/cli_commands.py +20 -12
  186. infrahub_sdk/ctl/config.py +8 -2
  187. infrahub_sdk/ctl/generator.py +6 -3
  188. infrahub_sdk/ctl/graphql.py +184 -0
  189. infrahub_sdk/ctl/repository.py +39 -1
  190. infrahub_sdk/ctl/schema.py +40 -10
  191. infrahub_sdk/ctl/task.py +110 -0
  192. infrahub_sdk/ctl/utils.py +4 -0
  193. infrahub_sdk/ctl/validate.py +5 -3
  194. infrahub_sdk/diff.py +4 -5
  195. infrahub_sdk/exceptions.py +2 -0
  196. infrahub_sdk/generator.py +7 -1
  197. infrahub_sdk/graphql/__init__.py +12 -0
  198. infrahub_sdk/graphql/constants.py +1 -0
  199. infrahub_sdk/graphql/plugin.py +85 -0
  200. infrahub_sdk/graphql/query.py +77 -0
  201. infrahub_sdk/{graphql.py → graphql/renderers.py} +88 -75
  202. infrahub_sdk/graphql/utils.py +40 -0
  203. infrahub_sdk/node/attribute.py +2 -0
  204. infrahub_sdk/node/node.py +28 -20
  205. infrahub_sdk/node/relationship.py +1 -3
  206. infrahub_sdk/playback.py +1 -2
  207. infrahub_sdk/protocols.py +54 -6
  208. infrahub_sdk/pytest_plugin/plugin.py +7 -4
  209. infrahub_sdk/pytest_plugin/utils.py +40 -0
  210. infrahub_sdk/repository.py +1 -2
  211. infrahub_sdk/schema/__init__.py +70 -4
  212. infrahub_sdk/schema/main.py +1 -0
  213. infrahub_sdk/schema/repository.py +8 -0
  214. infrahub_sdk/spec/models.py +7 -0
  215. infrahub_sdk/spec/object.py +54 -6
  216. infrahub_sdk/spec/processors/__init__.py +0 -0
  217. infrahub_sdk/spec/processors/data_processor.py +10 -0
  218. infrahub_sdk/spec/processors/factory.py +34 -0
  219. infrahub_sdk/spec/processors/range_expand_processor.py +56 -0
  220. infrahub_sdk/spec/range_expansion.py +118 -0
  221. infrahub_sdk/task/models.py +6 -4
  222. infrahub_sdk/timestamp.py +18 -6
  223. infrahub_sdk/transforms.py +1 -1
  224. {infrahub_server-1.4.12.dist-info → infrahub_server-1.5.0.dist-info}/METADATA +9 -10
  225. {infrahub_server-1.4.12.dist-info → infrahub_server-1.5.0.dist-info}/RECORD +233 -176
  226. infrahub_testcontainers/container.py +114 -2
  227. infrahub_testcontainers/docker-compose-cluster.test.yml +5 -0
  228. infrahub_testcontainers/docker-compose.test.yml +5 -0
  229. infrahub_testcontainers/models.py +2 -2
  230. infrahub_testcontainers/performance_test.py +4 -4
  231. infrahub/core/convert_object_type/conversion.py +0 -134
  232. {infrahub_server-1.4.12.dist-info → infrahub_server-1.5.0.dist-info}/LICENSE.txt +0 -0
  233. {infrahub_server-1.4.12.dist-info → infrahub_server-1.5.0.dist-info}/WHEEL +0 -0
  234. {infrahub_server-1.4.12.dist-info → infrahub_server-1.5.0.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,110 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional
4
+
5
+ import typer
6
+ from rich.console import Console
7
+ from rich.table import Table
8
+
9
+ from ..async_typer import AsyncTyper
10
+ from ..task.manager import TaskFilter
11
+ from ..task.models import Task, TaskState
12
+ from .client import initialize_client
13
+ from .parameters import CONFIG_PARAM
14
+ from .utils import catch_exception, init_logging
15
+
16
+ app = AsyncTyper()
17
+ console = Console()
18
+
19
+
20
+ @app.callback()
21
+ def callback() -> None:
22
+ """Manage Infrahub tasks."""
23
+
24
+
25
+ def _parse_states(states: list[str] | None) -> list[TaskState] | None:
26
+ if not states:
27
+ return None
28
+
29
+ parsed_states: list[TaskState] = []
30
+ for state in states:
31
+ normalized_state = state.strip().upper()
32
+ try:
33
+ parsed_states.append(TaskState(normalized_state))
34
+ except ValueError as exc: # pragma: no cover - typer will surface this as CLI error
35
+ raise typer.BadParameter(
36
+ f"Unsupported state '{state}'. Available states: {', '.join(item.value.lower() for item in TaskState)}"
37
+ ) from exc
38
+
39
+ return parsed_states
40
+
41
+
42
+ def _render_table(tasks: list[Task]) -> None:
43
+ table = Table(title="Infrahub Tasks", box=None)
44
+ table.add_column("ID", style="cyan", overflow="fold")
45
+ table.add_column("Title", style="magenta", overflow="fold")
46
+ table.add_column("State", style="green")
47
+ table.add_column("Progress", justify="right")
48
+ table.add_column("Workflow", overflow="fold")
49
+ table.add_column("Branch", overflow="fold")
50
+ table.add_column("Updated")
51
+
52
+ if not tasks:
53
+ table.add_row("-", "No tasks found", "-", "-", "-", "-", "-")
54
+ console.print(table)
55
+ return
56
+
57
+ for task in tasks:
58
+ progress = f"{task.progress:.0%}" if task.progress is not None else "-"
59
+ table.add_row(
60
+ task.id,
61
+ task.title,
62
+ task.state.value,
63
+ progress,
64
+ task.workflow or "-",
65
+ task.branch or "-",
66
+ task.updated_at.isoformat(),
67
+ )
68
+
69
+ console.print(table)
70
+
71
+
72
+ @app.command(name="list")
73
+ @catch_exception(console=console)
74
+ async def list_tasks(
75
+ state: list[str] = typer.Option(
76
+ None, "--state", "-s", help="Filter by task state. Can be provided multiple times."
77
+ ),
78
+ limit: Optional[int] = typer.Option(None, help="Maximum number of tasks to retrieve."),
79
+ offset: Optional[int] = typer.Option(None, help="Offset for pagination."),
80
+ include_related_nodes: bool = typer.Option(False, help="Include related nodes in the output."),
81
+ include_logs: bool = typer.Option(False, help="Include task logs in the output."),
82
+ json_output: bool = typer.Option(False, "--json", help="Output the result as JSON."),
83
+ debug: bool = False,
84
+ _: str = CONFIG_PARAM,
85
+ ) -> None:
86
+ """List Infrahub tasks."""
87
+
88
+ init_logging(debug=debug)
89
+
90
+ client = initialize_client()
91
+ filters = TaskFilter()
92
+ parsed_states = _parse_states(state)
93
+ if parsed_states:
94
+ filters.state = parsed_states
95
+
96
+ tasks = await client.task.filter(
97
+ filter=filters,
98
+ limit=limit,
99
+ offset=offset,
100
+ include_related_nodes=include_related_nodes,
101
+ include_logs=include_logs,
102
+ )
103
+
104
+ if json_output:
105
+ console.print_json(
106
+ data=[task.model_dump(mode="json") for task in tasks], indent=2, sort_keys=True, highlight=False
107
+ )
108
+ return
109
+
110
+ _render_table(tasks)
infrahub_sdk/ctl/utils.py CHANGED
@@ -118,6 +118,10 @@ def execute_graphql_query(
118
118
  query_str = query_object.load_query()
119
119
 
120
120
  client = initialize_client_sync()
121
+
122
+ if not branch:
123
+ branch = client.config.default_infrahub_branch
124
+
121
125
  response = client.execute_graphql(
122
126
  query=query_str,
123
127
  branch_name=branch,
@@ -14,7 +14,7 @@ from ..ctl.client import initialize_client, initialize_client_sync
14
14
  from ..ctl.exceptions import QueryNotFoundError
15
15
  from ..ctl.utils import catch_exception, find_graphql_query, parse_cli_vars
16
16
  from ..exceptions import GraphQLError
17
- from ..utils import get_branch, write_to_file
17
+ from ..utils import write_to_file
18
18
  from ..yaml import SchemaFile
19
19
  from .parameters import CONFIG_PARAM
20
20
  from .utils import load_yamlfile_from_disk_and_exit
@@ -68,8 +68,6 @@ def validate_graphql(
68
68
  ) -> None:
69
69
  """Validate the format of a GraphQL Query stored locally by executing it on a remote GraphQL endpoint"""
70
70
 
71
- branch = get_branch(branch)
72
-
73
71
  try:
74
72
  query_str = find_graphql_query(query)
75
73
  except QueryNotFoundError:
@@ -81,6 +79,10 @@ def validate_graphql(
81
79
  variables_dict = parse_cli_vars(variables)
82
80
 
83
81
  client = initialize_client_sync()
82
+
83
+ if not branch:
84
+ branch = client.config.default_infrahub_branch
85
+
84
86
  try:
85
87
  response = client.execute_graphql(
86
88
  query=query_str,
infrahub_sdk/diff.py CHANGED
@@ -37,8 +37,8 @@ class NodeDiffPeer(TypedDict):
37
37
 
38
38
  def get_diff_summary_query() -> str:
39
39
  return """
40
- query GetDiffTree($branch_name: String!) {
41
- DiffTree(branch: $branch_name) {
40
+ query GetDiffTree($branch_name: String!, $name: String, $from_time: DateTime, $to_time: DateTime) {
41
+ DiffTree(branch: $branch_name, name: $name, from_time: $from_time, to_time: $to_time) {
42
42
  nodes {
43
43
  uuid
44
44
  kind
@@ -117,12 +117,11 @@ def diff_tree_node_to_node_diff(node_dict: dict[str, Any], branch_name: str) ->
117
117
  )
118
118
  relationship_diff["peers"] = peer_diffs
119
119
  element_diffs.append(relationship_diff)
120
- node_diff = NodeDiff(
120
+ return NodeDiff(
121
121
  branch=branch_name,
122
122
  kind=str(node_dict.get("kind")),
123
123
  id=str(node_dict.get("uuid")),
124
- action=str(node_dict.get("action")),
124
+ action=str(node_dict.get("status")),
125
125
  display_label=str(node_dict.get("label")),
126
126
  elements=element_diffs,
127
127
  )
128
- return node_diff
@@ -17,6 +17,8 @@ class JsonDecodeError(Error):
17
17
  self.url = url
18
18
  if not self.message and self.url:
19
19
  self.message = f"Unable to decode response as JSON data from {self.url}"
20
+ if self.content:
21
+ self.message += f". Server response: {self.content}"
20
22
  super().__init__(self.message)
21
23
 
22
24
 
infrahub_sdk/generator.py CHANGED
@@ -26,6 +26,8 @@ class InfrahubGenerator(InfrahubOperation):
26
26
  generator_instance: str = "",
27
27
  params: dict | None = None,
28
28
  convert_query_response: bool = False,
29
+ execute_in_proposed_change: bool = True,
30
+ execute_after_merge: bool = True,
29
31
  logger: logging.Logger | None = None,
30
32
  request_context: RequestContext | None = None,
31
33
  ) -> None:
@@ -44,6 +46,8 @@ class InfrahubGenerator(InfrahubOperation):
44
46
  self._client: InfrahubClient | None = None
45
47
  self.logger = logger if logger else logging.getLogger("infrahub.tasks")
46
48
  self.request_context = request_context
49
+ self.execute_in_proposed_change = execute_in_proposed_change
50
+ self.execute_after_merge = execute_after_merge
47
51
 
48
52
  @property
49
53
  def subscribers(self) -> list[str] | None:
@@ -81,8 +85,10 @@ class InfrahubGenerator(InfrahubOperation):
81
85
  unpacked = data.get("data") or data
82
86
  await self.process_nodes(data=unpacked)
83
87
 
88
+ group_type = "CoreGeneratorGroup" if self.execute_after_merge else "CoreGeneratorAwareGroup"
89
+
84
90
  async with self._init_client.start_tracking(
85
- identifier=identifier, params=self.params, delete_unused_nodes=True, group_type="CoreGeneratorGroup"
91
+ identifier=identifier, params=self.params, delete_unused_nodes=True, group_type=group_type
86
92
  ) as self.client:
87
93
  await self.generate(data=unpacked)
88
94
 
@@ -0,0 +1,12 @@
1
+ from .constants import VARIABLE_TYPE_MAPPING
2
+ from .query import Mutation, Query
3
+ from .renderers import render_input_block, render_query_block, render_variables_to_string
4
+
5
+ __all__ = [
6
+ "VARIABLE_TYPE_MAPPING",
7
+ "Mutation",
8
+ "Query",
9
+ "render_input_block",
10
+ "render_query_block",
11
+ "render_variables_to_string",
12
+ ]
@@ -0,0 +1 @@
1
+ VARIABLE_TYPE_MAPPING = ((str, "String!"), (int, "Int!"), (float, "Float!"), (bool, "Boolean!"))
@@ -0,0 +1,85 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ from typing import TYPE_CHECKING
5
+
6
+ from ariadne_codegen.plugins.base import Plugin
7
+
8
+ if TYPE_CHECKING:
9
+ from graphql import ExecutableDefinitionNode
10
+
11
+
12
+ class FutureAnnotationPlugin(Plugin):
13
+ @staticmethod
14
+ def insert_future_annotation(module: ast.Module) -> ast.Module:
15
+ # First check if the future annotation is already present
16
+ for item in module.body:
17
+ if isinstance(item, ast.ImportFrom) and item.module == "__future__":
18
+ if any(alias.name == "annotations" for alias in item.names):
19
+ return module
20
+
21
+ module.body.insert(0, ast.ImportFrom(module="__future__", names=[ast.alias(name="annotations")], level=0))
22
+ return module
23
+
24
+ def generate_result_types_module(
25
+ self,
26
+ module: ast.Module,
27
+ operation_definition: ExecutableDefinitionNode, # noqa: ARG002
28
+ ) -> ast.Module:
29
+ return self.insert_future_annotation(module)
30
+
31
+
32
+ class StandardTypeHintPlugin(Plugin):
33
+ @classmethod
34
+ def replace_list_in_subscript(cls, subscript: ast.Subscript) -> ast.Subscript:
35
+ if isinstance(subscript.value, ast.Name) and subscript.value.id == "List":
36
+ subscript.value.id = "list"
37
+ if isinstance(subscript.slice, ast.Subscript):
38
+ subscript.slice = cls.replace_list_in_subscript(subscript.slice)
39
+
40
+ return subscript
41
+
42
+ @classmethod
43
+ def replace_list_annotations(cls, module: ast.Module) -> ast.Module:
44
+ for item in module.body:
45
+ if not isinstance(item, ast.ClassDef):
46
+ continue
47
+
48
+ # replace List with list in the annotations when list is used as a type
49
+ for class_item in item.body:
50
+ if not isinstance(class_item, ast.AnnAssign):
51
+ continue
52
+ if isinstance(class_item.annotation, ast.Subscript):
53
+ class_item.annotation = cls.replace_list_in_subscript(class_item.annotation)
54
+
55
+ return module
56
+
57
+ def generate_result_types_module(
58
+ self,
59
+ module: ast.Module,
60
+ operation_definition: ExecutableDefinitionNode, # noqa: ARG002
61
+ ) -> ast.Module:
62
+ module = FutureAnnotationPlugin.insert_future_annotation(module)
63
+ return self.replace_list_annotations(module)
64
+
65
+
66
+ class PydanticBaseModelPlugin(Plugin):
67
+ @staticmethod
68
+ def find_base_model_index(module: ast.Module) -> int:
69
+ for idx, item in enumerate(module.body):
70
+ if isinstance(item, ast.ImportFrom) and item.module == "base_model":
71
+ return idx
72
+ raise ValueError("BaseModel not found in module")
73
+
74
+ @classmethod
75
+ def replace_base_model_import(cls, module: ast.Module) -> ast.Module:
76
+ base_model_index = cls.find_base_model_index(module)
77
+ module.body[base_model_index] = ast.ImportFrom(module="pydantic", names=[ast.alias(name="BaseModel")], level=0)
78
+ return module
79
+
80
+ def generate_result_types_module(
81
+ self,
82
+ module: ast.Module,
83
+ operation_definition: ExecutableDefinitionNode, # noqa: ARG002
84
+ ) -> ast.Module:
85
+ return self.replace_base_model_import(module)
@@ -0,0 +1,77 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from .renderers import render_input_block, render_query_block, render_variables_to_string
6
+
7
+
8
+ class BaseGraphQLQuery:
9
+ query_type: str = "not-defined"
10
+ indentation: int = 4
11
+
12
+ def __init__(self, query: dict, variables: dict | None = None, name: str | None = None):
13
+ self.query = query
14
+ self.variables = variables
15
+ self.name = name or ""
16
+
17
+ def render_first_line(self) -> str:
18
+ first_line = self.query_type
19
+
20
+ if self.name:
21
+ first_line += " " + self.name
22
+
23
+ if self.variables:
24
+ first_line += f" ({render_variables_to_string(self.variables)})"
25
+
26
+ first_line += " {"
27
+
28
+ return first_line
29
+
30
+
31
+ class Query(BaseGraphQLQuery):
32
+ query_type = "query"
33
+
34
+ def render(self, convert_enum: bool = False) -> str:
35
+ lines = [self.render_first_line()]
36
+ lines.extend(
37
+ render_query_block(
38
+ data=self.query, indentation=self.indentation, offset=self.indentation, convert_enum=convert_enum
39
+ )
40
+ )
41
+ lines.append("}")
42
+
43
+ return "\n" + "\n".join(lines) + "\n"
44
+
45
+
46
+ class Mutation(BaseGraphQLQuery):
47
+ query_type = "mutation"
48
+
49
+ def __init__(self, *args: Any, mutation: str, input_data: dict, **kwargs: Any):
50
+ self.input_data = input_data
51
+ self.mutation = mutation
52
+ super().__init__(*args, **kwargs)
53
+
54
+ def render(self, convert_enum: bool = False) -> str:
55
+ lines = [self.render_first_line()]
56
+ lines.append(" " * self.indentation + f"{self.mutation}(")
57
+ lines.extend(
58
+ render_input_block(
59
+ data=self.input_data,
60
+ indentation=self.indentation,
61
+ offset=self.indentation * 2,
62
+ convert_enum=convert_enum,
63
+ )
64
+ )
65
+ lines.append(" " * self.indentation + "){")
66
+ lines.extend(
67
+ render_query_block(
68
+ data=self.query,
69
+ indentation=self.indentation,
70
+ offset=self.indentation * 2,
71
+ convert_enum=convert_enum,
72
+ )
73
+ )
74
+ lines.append(" " * self.indentation + "}")
75
+ lines.append("}")
76
+
77
+ return "\n" + "\n".join(lines) + "\n"
@@ -1,14 +1,42 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import json
3
4
  from enum import Enum
4
5
  from typing import Any
5
6
 
6
7
  from pydantic import BaseModel
7
8
 
8
- VARIABLE_TYPE_MAPPING = ((str, "String!"), (int, "Int!"), (float, "Float!"), (bool, "Boolean!"))
9
+ from .constants import VARIABLE_TYPE_MAPPING
9
10
 
10
11
 
11
- def convert_to_graphql_as_string(value: str | bool | list | BaseModel | Enum | Any, convert_enum: bool = False) -> str: # noqa: PLR0911
12
+ def convert_to_graphql_as_string(value: Any, convert_enum: bool = False) -> str: # noqa: PLR0911
13
+ """Convert a Python value to its GraphQL string representation.
14
+
15
+ This function handles various Python types and converts them to their appropriate
16
+ GraphQL string format, including proper quoting, formatting, and special handling
17
+ for different data types.
18
+
19
+ Args:
20
+ value: The value to convert to GraphQL string format. Can be None, str, bool,
21
+ int, float, Enum, list, BaseModel, or any other type.
22
+ convert_enum: If True, converts Enum values to their underlying value instead
23
+ of their name. Defaults to False.
24
+
25
+ Returns:
26
+ str: The GraphQL string representation of the value.
27
+
28
+ Examples:
29
+ >>> convert_to_graphql_as_string("hello")
30
+ '"hello"'
31
+ >>> convert_to_graphql_as_string(True)
32
+ 'true'
33
+ >>> convert_to_graphql_as_string([1, 2, 3])
34
+ '[1, 2, 3]'
35
+ >>> convert_to_graphql_as_string(None)
36
+ 'null'
37
+ """
38
+ if value is None:
39
+ return "null"
12
40
  if isinstance(value, str) and value.startswith("$"):
13
41
  return value
14
42
  if isinstance(value, Enum):
@@ -16,7 +44,9 @@ def convert_to_graphql_as_string(value: str | bool | list | BaseModel | Enum | A
16
44
  return convert_to_graphql_as_string(value=value.value, convert_enum=True)
17
45
  return value.name
18
46
  if isinstance(value, str):
19
- return f'"{value}"'
47
+ # Use json.dumps() to properly escape the string according to JSON rules,
48
+ # which are compatible with GraphQL string escaping
49
+ return json.dumps(value)
20
50
  if isinstance(value, bool):
21
51
  return repr(value).lower()
22
52
  if isinstance(value, list):
@@ -51,6 +81,34 @@ def render_variables_to_string(data: dict[str, type[str | int | float | bool]])
51
81
 
52
82
 
53
83
  def render_query_block(data: dict, offset: int = 4, indentation: int = 4, convert_enum: bool = False) -> list[str]:
84
+ """Render a dictionary structure as a GraphQL query block with proper formatting.
85
+
86
+ This function recursively processes a dictionary to generate GraphQL query syntax
87
+ with proper indentation, handling of aliases, filters, and nested structures.
88
+ Special keys like "@filters" and "@alias" are processed for GraphQL-specific
89
+ formatting.
90
+
91
+ Args:
92
+ data: Dictionary representing the GraphQL query structure. Can contain
93
+ nested dictionaries, special keys like "@filters" and "@alias", and
94
+ various value types.
95
+ offset: Number of spaces to use for initial indentation. Defaults to 4.
96
+ indentation: Number of spaces to add for each nesting level. Defaults to 4.
97
+ convert_enum: If True, converts Enum values to their underlying value.
98
+ Defaults to False.
99
+
100
+ Returns:
101
+ list[str]: List of formatted lines representing the GraphQL query block.
102
+
103
+ Examples:
104
+ >>> data = {"user": {"name": None, "email": None}}
105
+ >>> render_query_block(data)
106
+ [' user {', ' name', ' email', ' }']
107
+
108
+ >>> data = {"user": {"@alias": "u", "@filters": {"id": 123}, "name": None}}
109
+ >>> render_query_block(data)
110
+ [' u: user(id: 123) {', ' name', ' }']
111
+ """
54
112
  FILTERS_KEY = "@filters"
55
113
  ALIAS_KEY = "@alias"
56
114
  KEYWORDS_TO_SKIP = [FILTERS_KEY, ALIAS_KEY]
@@ -92,6 +150,33 @@ def render_query_block(data: dict, offset: int = 4, indentation: int = 4, conver
92
150
 
93
151
 
94
152
  def render_input_block(data: dict, offset: int = 4, indentation: int = 4, convert_enum: bool = False) -> list[str]:
153
+ """Render a dictionary structure as a GraphQL input block with proper formatting.
154
+
155
+ This function recursively processes a dictionary to generate GraphQL input syntax
156
+ with proper indentation, handling nested objects, arrays, and various data types.
157
+ Unlike query blocks, input blocks don't handle special keys like "@filters" or
158
+ "@alias" and focus on data structure representation.
159
+
160
+ Args:
161
+ data: Dictionary representing the GraphQL input structure. Can contain
162
+ nested dictionaries, lists, and various value types.
163
+ offset: Number of spaces to use for initial indentation. Defaults to 4.
164
+ indentation: Number of spaces to add for each nesting level. Defaults to 4.
165
+ convert_enum: If True, converts Enum values to their underlying value.
166
+ Defaults to False.
167
+
168
+ Returns:
169
+ list[str]: List of formatted lines representing the GraphQL input block.
170
+
171
+ Examples:
172
+ >>> data = {"name": "John", "age": 30}
173
+ >>> render_input_block(data)
174
+ [' name: "John"', ' age: 30']
175
+
176
+ >>> data = {"user": {"name": "John", "hobbies": ["reading", "coding"]}}
177
+ >>> render_input_block(data)
178
+ [' user: {', ' name: "John"', ' hobbies: [', ' "reading",', ' "coding",', ' ]', ' }']
179
+ """
95
180
  offset_str = " " * offset
96
181
  lines = []
97
182
  for key, value in data.items():
@@ -125,75 +210,3 @@ def render_input_block(data: dict, offset: int = 4, indentation: int = 4, conver
125
210
  else:
126
211
  lines.append(f"{offset_str}{key}: {convert_to_graphql_as_string(value=value, convert_enum=convert_enum)}")
127
212
  return lines
128
-
129
-
130
- class BaseGraphQLQuery:
131
- query_type: str = "not-defined"
132
- indentation: int = 4
133
-
134
- def __init__(self, query: dict, variables: dict | None = None, name: str | None = None):
135
- self.query = query
136
- self.variables = variables
137
- self.name = name or ""
138
-
139
- def render_first_line(self) -> str:
140
- first_line = self.query_type
141
-
142
- if self.name:
143
- first_line += " " + self.name
144
-
145
- if self.variables:
146
- first_line += f" ({render_variables_to_string(self.variables)})"
147
-
148
- first_line += " {"
149
-
150
- return first_line
151
-
152
-
153
- class Query(BaseGraphQLQuery):
154
- query_type = "query"
155
-
156
- def render(self, convert_enum: bool = False) -> str:
157
- lines = [self.render_first_line()]
158
- lines.extend(
159
- render_query_block(
160
- data=self.query, indentation=self.indentation, offset=self.indentation, convert_enum=convert_enum
161
- )
162
- )
163
- lines.append("}")
164
-
165
- return "\n" + "\n".join(lines) + "\n"
166
-
167
-
168
- class Mutation(BaseGraphQLQuery):
169
- query_type = "mutation"
170
-
171
- def __init__(self, *args: Any, mutation: str, input_data: dict, **kwargs: Any):
172
- self.input_data = input_data
173
- self.mutation = mutation
174
- super().__init__(*args, **kwargs)
175
-
176
- def render(self, convert_enum: bool = False) -> str:
177
- lines = [self.render_first_line()]
178
- lines.append(" " * self.indentation + f"{self.mutation}(")
179
- lines.extend(
180
- render_input_block(
181
- data=self.input_data,
182
- indentation=self.indentation,
183
- offset=self.indentation * 2,
184
- convert_enum=convert_enum,
185
- )
186
- )
187
- lines.append(" " * self.indentation + "){")
188
- lines.extend(
189
- render_query_block(
190
- data=self.query,
191
- indentation=self.indentation,
192
- offset=self.indentation * 2,
193
- convert_enum=convert_enum,
194
- )
195
- )
196
- lines.append(" " * self.indentation + "}")
197
- lines.append("}")
198
-
199
- return "\n" + "\n".join(lines) + "\n"
@@ -0,0 +1,40 @@
1
+ import ast
2
+
3
+
4
+ def get_class_def_index(module: ast.Module) -> int:
5
+ """Get the index of the first class definition in the module.
6
+ It's useful to insert other classes before the first class definition."""
7
+ for idx, item in enumerate(module.body):
8
+ if isinstance(item, ast.ClassDef):
9
+ return idx
10
+ return -1
11
+
12
+
13
+ def insert_fragments_inline(module: ast.Module, fragment: ast.Module) -> ast.Module:
14
+ """Insert the Pydantic classes for the fragments inline into the module.
15
+
16
+ If no class definitions exist in module, fragments are appended to the end.
17
+ """
18
+ module_class_def_index = get_class_def_index(module)
19
+
20
+ fragment_classes: list[ast.ClassDef] = [item for item in fragment.body if isinstance(item, ast.ClassDef)]
21
+
22
+ # Handle edge case when no class definitions exist
23
+ if module_class_def_index == -1:
24
+ # Append fragments to the end of the module
25
+ module.body.extend(fragment_classes)
26
+ else:
27
+ # Insert fragments before the first class definition
28
+ for idx, item in enumerate(fragment_classes):
29
+ module.body.insert(module_class_def_index + idx, item)
30
+
31
+ return module
32
+
33
+
34
+ def remove_fragment_import(module: ast.Module) -> ast.Module:
35
+ """Remove the fragment import from the module."""
36
+ for item in module.body:
37
+ if isinstance(item, ast.ImportFrom) and item.module == "fragments":
38
+ module.body.remove(item)
39
+ return module
40
+ return module
@@ -76,6 +76,8 @@ class Attribute:
76
76
  variables: dict[str, Any] = {}
77
77
 
78
78
  if self.value is None:
79
+ if self._schema.optional and self.value_has_been_mutated:
80
+ data["value"] = None
79
81
  return data
80
82
 
81
83
  if isinstance(self.value, str):