fragment-python 1.0.0__tar.gz → 1.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. {fragment_python-1.0.0 → fragment_python-1.1.0}/PKG-INFO +33 -4
  2. {fragment_python-1.0.0 → fragment_python-1.1.0}/README.md +31 -2
  3. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/client/async_client.py +8 -5
  4. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/client/sync_client.py +8 -5
  5. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/codegen/helpers.py +7 -0
  6. fragment_python-1.1.0/fragment/codegen/main.py +98 -0
  7. fragment_python-1.1.0/fragment/codegen/plugins/generate_typed_entries.py +204 -0
  8. fragment_python-1.1.0/fragment/codegen/typed_entries.py +524 -0
  9. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/exceptions.py +2 -2
  10. fragment_python-1.1.0/fragment/py.typed +0 -0
  11. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/sdk/__init__.py +35 -0
  12. fragment_python-1.1.0/fragment/sdk/add_ledger_entries.py +84 -0
  13. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/sdk/async_client.py +8 -5
  14. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/sdk/client.py +66 -1
  15. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/sdk/enums.py +6 -0
  16. {fragment_python-1.0.0/fragment/sync_sdk → fragment_python-1.1.0/fragment/sdk}/input_types.py +42 -16
  17. fragment_python-1.1.0/fragment/sdk/typed_entries.py +95 -0
  18. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/std_queries/queries.graphql +46 -0
  19. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/sync_sdk/__init__.py +35 -0
  20. fragment_python-1.1.0/fragment/sync_sdk/add_ledger_entries.py +84 -0
  21. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/sync_sdk/async_client.py +8 -5
  22. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/sync_sdk/client.py +66 -1
  23. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/sync_sdk/enums.py +6 -0
  24. {fragment_python-1.0.0/fragment/sdk → fragment_python-1.1.0/fragment/sync_sdk}/input_types.py +42 -16
  25. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/sync_sdk/sync_client.py +8 -5
  26. fragment_python-1.1.0/fragment/sync_sdk/typed_entries.py +95 -0
  27. fragment_python-1.1.0/pyproject.toml +85 -0
  28. fragment_python-1.0.0/fragment/codegen/main.py +0 -71
  29. fragment_python-1.0.0/pyproject.toml +0 -45
  30. {fragment_python-1.0.0 → fragment_python-1.1.0}/LICENSE +0 -0
  31. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/__init__.py +0 -0
  32. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/client/__init__.py +0 -0
  33. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/codegen/__init__.py +0 -0
  34. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/codegen/plugins/__init__.py +0 -0
  35. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/codegen/plugins/generate_client_method.py +0 -0
  36. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/codegen/plugins/get_file_comment.py +0 -0
  37. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/logger.py +0 -0
  38. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/sdk/add_ledger_entry.py +0 -0
  39. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/sdk/add_ledger_entry_runtime.py +0 -0
  40. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/sdk/base_model.py +0 -0
  41. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/sdk/create_custom_currency.py +0 -0
  42. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/sdk/create_custom_link.py +0 -0
  43. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/sdk/create_ledger.py +0 -0
  44. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/sdk/delete_custom_txs.py +0 -0
  45. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/sdk/delete_ledger.py +0 -0
  46. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/sdk/delete_schema.py +0 -0
  47. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/sdk/get_account_data_migrations.py +0 -0
  48. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/sdk/get_entries_to_migrate_for_ledger_account_data_migration.py +0 -0
  49. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/sdk/get_entries_to_migrate_for_ledger_entry_data_migration.py +0 -0
  50. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/sdk/get_entry_data_migrations.py +0 -0
  51. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/sdk/get_ledger.py +0 -0
  52. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/sdk/get_ledger_account_balance.py +0 -0
  53. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/sdk/get_ledger_account_balance_with_child_rollup.py +0 -0
  54. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/sdk/get_ledger_account_lines.py +0 -0
  55. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/sdk/get_ledger_entry.py +0 -0
  56. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/sdk/get_schema.py +0 -0
  57. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/sdk/get_workspace.py +0 -0
  58. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/sdk/list_ledger_account_balances.py +0 -0
  59. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/sdk/list_ledger_accounts.py +0 -0
  60. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/sdk/list_ledger_entries.py +0 -0
  61. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/sdk/list_ledger_entry_group_balances.py +0 -0
  62. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/sdk/list_multi_currency_ledger_account_balances.py +0 -0
  63. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/sdk/migrate_ledger_entry.py +0 -0
  64. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/sdk/reconcile_tx.py +0 -0
  65. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/sdk/reconcile_tx_runtime.py +0 -0
  66. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/sdk/reverse_ledger_entry.py +0 -0
  67. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/sdk/store_schema.py +0 -0
  68. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/sdk/sync_custom_accounts.py +0 -0
  69. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/sdk/sync_custom_txs.py +0 -0
  70. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/sdk/update_ledger.py +0 -0
  71. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/sdk/update_ledger_entry.py +0 -0
  72. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/sync_sdk/add_ledger_entry.py +0 -0
  73. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/sync_sdk/add_ledger_entry_runtime.py +0 -0
  74. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/sync_sdk/base_model.py +0 -0
  75. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/sync_sdk/create_custom_currency.py +0 -0
  76. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/sync_sdk/create_custom_link.py +0 -0
  77. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/sync_sdk/create_ledger.py +0 -0
  78. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/sync_sdk/delete_custom_txs.py +0 -0
  79. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/sync_sdk/delete_ledger.py +0 -0
  80. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/sync_sdk/delete_schema.py +0 -0
  81. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/sync_sdk/get_account_data_migrations.py +0 -0
  82. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/sync_sdk/get_entries_to_migrate_for_ledger_account_data_migration.py +0 -0
  83. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/sync_sdk/get_entries_to_migrate_for_ledger_entry_data_migration.py +0 -0
  84. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/sync_sdk/get_entry_data_migrations.py +0 -0
  85. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/sync_sdk/get_ledger.py +0 -0
  86. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/sync_sdk/get_ledger_account_balance.py +0 -0
  87. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/sync_sdk/get_ledger_account_balance_with_child_rollup.py +0 -0
  88. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/sync_sdk/get_ledger_account_lines.py +0 -0
  89. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/sync_sdk/get_ledger_entry.py +0 -0
  90. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/sync_sdk/get_schema.py +0 -0
  91. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/sync_sdk/get_workspace.py +0 -0
  92. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/sync_sdk/list_ledger_account_balances.py +0 -0
  93. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/sync_sdk/list_ledger_accounts.py +0 -0
  94. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/sync_sdk/list_ledger_entries.py +0 -0
  95. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/sync_sdk/list_ledger_entry_group_balances.py +0 -0
  96. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/sync_sdk/list_multi_currency_ledger_account_balances.py +0 -0
  97. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/sync_sdk/migrate_ledger_entry.py +0 -0
  98. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/sync_sdk/reconcile_tx.py +0 -0
  99. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/sync_sdk/reconcile_tx_runtime.py +0 -0
  100. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/sync_sdk/reverse_ledger_entry.py +0 -0
  101. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/sync_sdk/store_schema.py +0 -0
  102. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/sync_sdk/sync_custom_accounts.py +0 -0
  103. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/sync_sdk/sync_custom_txs.py +0 -0
  104. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/sync_sdk/update_ledger.py +0 -0
  105. {fragment_python-1.0.0 → fragment_python-1.1.0}/fragment/sync_sdk/update_ledger_entry.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fragment-python
3
- Version: 1.0.0
3
+ Version: 1.1.0
4
4
  Summary: Python SDK for https://fragment.dev/
5
5
  License: Apache 2.0
6
6
  License-File: LICENSE
@@ -14,7 +14,7 @@ Classifier: Programming Language :: Python :: 3.12
14
14
  Classifier: Programming Language :: Python :: 3.13
15
15
  Classifier: Programming Language :: Python :: 3.14
16
16
  Requires-Dist: ariadne-codegen (>=0.17.2,<0.18.0)
17
- Requires-Dist: authlib (>=1.6.9,<2.0.0)
17
+ Requires-Dist: authlib (>=1.6.12,<2.0.0)
18
18
  Requires-Dist: click (>=8.1.7,<9.0.0)
19
19
  Requires-Dist: graphql-core (>=3.2.3,<4.0.0)
20
20
  Requires-Dist: httpx (>=0.28.1,<0.29.0)
@@ -92,7 +92,7 @@ print(get_schema_result.schema_.json())
92
92
 
93
93
  ### Post a Ledger Entry
94
94
 
95
- To [post](https://fragment.dev/docs#post-ledger-entries-post-to-the-api) a Ledger Entry defined in your Schema:
95
+ To [post](https://fragment.dev/guides/post-ledger-entries#post-to-the-api) a Ledger Entry defined in your Schema:
96
96
 
97
97
  ```python
98
98
  await graphql_client.add_ledger_entry(
@@ -107,9 +107,38 @@ await graphql_client.add_ledger_entry(
107
107
  )
108
108
  ```
109
109
 
110
+ ### Post a batch of Ledger Entries
111
+
112
+ To [post](https://fragment.dev/guides/post-ledger-entries#batch-ledger-entries) a batch of Ledger Entries atomically:
113
+
114
+ ```python
115
+ from fragment.sdk.typed_entries import UserFundsAccountV1
116
+
117
+ await graphql_client.add_ledger_entries(
118
+ entries=[
119
+ UserFundsAccountV1(
120
+ ik="some-ik-1",
121
+ ledger_ik="your-ledger-ik",
122
+ posted="1968-01-01T16:45:00Z",
123
+ user_id="user-1",
124
+ funding_amount="20000",
125
+ ),
126
+ UserFundsAccountV1(
127
+ ik="some-ik-2",
128
+ ledger_ik="your-ledger-ik",
129
+ posted="1968-01-01T16:45:00Z",
130
+ user_id="user-2",
131
+ funding_amount="20000",
132
+ ),
133
+ ]
134
+ )
135
+ ```
136
+
137
+ Construct the entries in the batch using the strongly-typed models for your Schema in the `typed_entries` module of your generated client.
138
+
110
139
  ### Read a Ledger Account's Balance
111
140
 
112
- To read a Ledger Account's [balance](https://fragment.dev/docs#read-balances-latest):
141
+ To read a Ledger Account's [balance](https://fragment.dev/guides/read-balances#latest):
113
142
 
114
143
  ```python
115
144
  from fragment.sdk.enums import CurrencyCode
@@ -68,7 +68,7 @@ print(get_schema_result.schema_.json())
68
68
 
69
69
  ### Post a Ledger Entry
70
70
 
71
- To [post](https://fragment.dev/docs#post-ledger-entries-post-to-the-api) a Ledger Entry defined in your Schema:
71
+ To [post](https://fragment.dev/guides/post-ledger-entries#post-to-the-api) a Ledger Entry defined in your Schema:
72
72
 
73
73
  ```python
74
74
  await graphql_client.add_ledger_entry(
@@ -83,9 +83,38 @@ await graphql_client.add_ledger_entry(
83
83
  )
84
84
  ```
85
85
 
86
+ ### Post a batch of Ledger Entries
87
+
88
+ To [post](https://fragment.dev/guides/post-ledger-entries#batch-ledger-entries) a batch of Ledger Entries atomically:
89
+
90
+ ```python
91
+ from fragment.sdk.typed_entries import UserFundsAccountV1
92
+
93
+ await graphql_client.add_ledger_entries(
94
+ entries=[
95
+ UserFundsAccountV1(
96
+ ik="some-ik-1",
97
+ ledger_ik="your-ledger-ik",
98
+ posted="1968-01-01T16:45:00Z",
99
+ user_id="user-1",
100
+ funding_amount="20000",
101
+ ),
102
+ UserFundsAccountV1(
103
+ ik="some-ik-2",
104
+ ledger_ik="your-ledger-ik",
105
+ posted="1968-01-01T16:45:00Z",
106
+ user_id="user-2",
107
+ funding_amount="20000",
108
+ ),
109
+ ]
110
+ )
111
+ ```
112
+
113
+ Construct the entries in the batch using the strongly-typed models for your Schema in the `typed_entries` module of your generated client.
114
+
86
115
  ### Read a Ledger Account's Balance
87
116
 
88
- To read a Ledger Account's [balance](https://fragment.dev/docs#read-balances-latest):
117
+ To read a Ledger Account's [balance](https://fragment.dev/guides/read-balances#latest):
89
118
 
90
119
  ```python
91
120
  from fragment.sdk.enums import CurrencyCode
@@ -35,17 +35,20 @@ class AsyncFragmentClient(AsyncBaseClient):
35
35
  super().__init__(url=api_url, http_client=http_client)
36
36
 
37
37
  self.auth_url = auth_url
38
- self.expiration_time = None
39
- self.token = None
38
+ self.expiration_time: Optional[float] = None
39
+ self.token: Optional[Dict[str, Any]] = None
40
40
  self.oauth2_client = AsyncOAuth2Client(
41
41
  client_id, client_secret, scope=auth_scope
42
42
  )
43
43
 
44
- async def refresh_token(self):
44
+ async def refresh_token(self) -> None:
45
45
  now = time.time()
46
46
  if self.expiration_time is None or self.expiration_time <= now:
47
- self.token = await self.oauth2_client.fetch_token(self.auth_url)
48
- self.expiration_time = now + self.token["expires_in"]
47
+ # Held in a local because `self.token` is declared `dict | None`,
48
+ # so reading the attribute back is not narrowed by the assignment.
49
+ token = await self.oauth2_client.fetch_token(self.auth_url)
50
+ self.token = token
51
+ self.expiration_time = now + token["expires_in"]
49
52
 
50
53
  async def execute(
51
54
  self,
@@ -33,15 +33,18 @@ class SyncFragmentClient(BaseClient):
33
33
  super().__init__(url=api_url, http_client=http_client)
34
34
 
35
35
  self.auth_url = auth_url
36
- self.expiration_time = None
37
- self.token = None
36
+ self.expiration_time: Optional[float] = None
37
+ self.token: Optional[Dict[str, Any]] = None
38
38
  self.oauth2_client = OAuth2Client(client_id, client_secret, scope=auth_scope)
39
39
 
40
- def refresh_token(self):
40
+ def refresh_token(self) -> None:
41
41
  now = time.time()
42
42
  if self.expiration_time is None or self.expiration_time <= now:
43
- self.token = self.oauth2_client.fetch_token(self.auth_url)
44
- self.expiration_time = now + self.token["expires_in"]
43
+ # Held in a local because `self.token` is declared `dict | None`,
44
+ # so reading the attribute back is not narrowed by the assignment.
45
+ token = self.oauth2_client.fetch_token(self.auth_url)
46
+ self.token = token
47
+ self.expiration_time = now + token["expires_in"]
45
48
 
46
49
  def execute(
47
50
  self,
@@ -39,9 +39,16 @@ def get_codegen_config(
39
39
  base_client_name=client_name,
40
40
  base_client_file_path=get_project_path_relative_to_file(file_path),
41
41
  async_client=False if use_sync_client else True,
42
+ # Order matters. GenerateTypedLedgerEntries copies annotations
43
+ # off the generated client methods, so it has to run after
44
+ # RewriteUnsetTypeMethodArguments has turned
45
+ # `Union[Optional[X], UnsetType]` into `Optional[X]`. Listed
46
+ # earlier, it emits `UnsetType` into a module that never imports
47
+ # it. collect_annotations raises if that ever happens.
42
48
  plugins=[
43
49
  "fragment.codegen.plugins.get_file_comment.GenerateFileComment",
44
50
  "fragment.codegen.plugins.generate_client_method.RewriteUnsetTypeMethodArguments",
51
+ "fragment.codegen.plugins.generate_typed_entries.GenerateTypedLedgerEntries",
45
52
  ],
46
53
  ),
47
54
  },
@@ -0,0 +1,98 @@
1
+ import contextlib
2
+ import logging
3
+ import sys
4
+ import tempfile
5
+ from typing import Iterator
6
+
7
+ import click
8
+ import httpx
9
+ from ariadne_codegen.main import client as generate_graphql_client
10
+
11
+ from fragment.codegen.helpers import get_codegen_config, get_standard_queries
12
+ from fragment.logger import console_log
13
+
14
+ logging.getLogger("httpx").setLevel(logging.WARNING)
15
+
16
+
17
+ GRAPHQL_SCHEMA_API_URL = "https://api.us-west-2.fragment.dev/schema.graphql"
18
+
19
+
20
+ @contextlib.contextmanager
21
+ def resolved_schema_path(schema_path: str | None) -> Iterator[str]:
22
+ """Yield a path to the schema to generate against.
23
+
24
+ A local path is used as-is, which keeps generation reproducible and offline.
25
+ Without one the current schema is downloaded to a temporary file, so output
26
+ depends on whatever the API looks like at that moment.
27
+ """
28
+ if schema_path is not None:
29
+ console_log.info(f"Using the GraphQL schema at {schema_path}")
30
+ yield schema_path
31
+ return
32
+
33
+ console_log.info(f"Downloading the GraphQL schema from {GRAPHQL_SCHEMA_API_URL}")
34
+ try:
35
+ response = httpx.get(GRAPHQL_SCHEMA_API_URL)
36
+ except httpx.RequestError as error:
37
+ console_log.error(f"An error occurred while downloading the schema: {error}")
38
+ sys.exit(1)
39
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".graphql") as schema_file:
40
+ schema_file.write(response.text)
41
+ schema_file.flush()
42
+ yield schema_file.name
43
+
44
+
45
+ @click.command()
46
+ @click.option(
47
+ "-i",
48
+ "--input-dir",
49
+ default=None,
50
+ help="Path to your Schema queries",
51
+ required=True,
52
+ )
53
+ @click.option(
54
+ "-n",
55
+ "--target-package-name",
56
+ default="fragment_graphql_client",
57
+ help="The package name for the generated SDK",
58
+ required=False,
59
+ )
60
+ @click.option(
61
+ "-o",
62
+ "--output-dir",
63
+ default=None,
64
+ help="The output directory for the generated SDK. Defaults to CWD.",
65
+ required=False,
66
+ )
67
+ @click.option(
68
+ "-s",
69
+ "--schema-path",
70
+ default=None,
71
+ type=click.Path(exists=True, dir_okay=False, readable=True),
72
+ help=(
73
+ "Path to a local GraphQL schema. Defaults to downloading the current "
74
+ "schema. Pass a file to make generation reproducible and offline."
75
+ ),
76
+ required=False,
77
+ )
78
+ @click.option(
79
+ "--sync",
80
+ help="Generate a synchronous client. Defaults to async.",
81
+ required=False,
82
+ is_flag=True,
83
+ )
84
+ def run(input_dir, target_package_name, sync, output_dir=None, schema_path=None):
85
+ with resolved_schema_path(schema_path) as resolved, tempfile.NamedTemporaryFile(
86
+ dir=input_dir, mode="w", suffix=".graphql"
87
+ ) as standard_query_file:
88
+ # Write and flush the standard queries to the provided input
89
+ standard_query_file.write(get_standard_queries())
90
+ standard_query_file.flush()
91
+ config_dict = get_codegen_config(
92
+ use_sync_client=sync,
93
+ schema_path=resolved,
94
+ queries_path=input_dir,
95
+ target_package_name=target_package_name,
96
+ target_package_path=output_dir,
97
+ )
98
+ generate_graphql_client(config_dict)
@@ -0,0 +1,204 @@
1
+ import ast
2
+ from pathlib import Path
3
+
4
+ from ariadne_codegen.plugins.base import Plugin
5
+ from graphql import OperationDefinitionNode
6
+
7
+ from fragment.codegen.typed_entries import (
8
+ MODULE_NAME,
9
+ EntrySpec,
10
+ collect_annotations,
11
+ extract_entry_spec,
12
+ render_module,
13
+ resolve_class_names,
14
+ )
15
+ from fragment.logger import console_log
16
+
17
+ ADD_LEDGER_ENTRIES_OPERATION = "addLedgerEntries"
18
+ ENTRIES_ARGUMENT = "entries"
19
+
20
+
21
+ class GenerateTypedLedgerEntries(Plugin):
22
+ """Emit strongly-typed `addLedgerEntries` payload models.
23
+
24
+ `addLedgerEntries` accepts a list of a single input type whose `parameters`
25
+ field is an opaque `JSON` scalar, so GraphQL alone cannot type an individual
26
+ entry in a batch. The per-entry-type `addLedgerEntry` operations already in
27
+ the input queries do carry that information, so this plugin recovers it and
28
+ renders one pydantic model per entry type into a `typed_entries` module.
29
+
30
+ Specs are collected in `generate_client_method`, which ariadne calls for
31
+ every operation. The module is written in `generate_init_code`, the last
32
+ hook to run, by which point every operation has been seen.
33
+ """
34
+
35
+ def __init__(self, schema, config_dict: dict) -> None:
36
+ super().__init__(schema, config_dict)
37
+ settings = config_dict.get("tool", {}).get("ariadne-codegen", {})
38
+ self.package_path = Path(
39
+ settings.get("target_package_path", Path.cwd())
40
+ ) / settings.get("target_package_name", "graphql_client")
41
+ self.specs: list[EntrySpec] = []
42
+ # Both names below are owned upstream in fragment-dev/graphql-queries,
43
+ # so track whether each was actually seen rather than assuming.
44
+ self.saw_batch_operation = False
45
+ self.widened_entries_argument = False
46
+
47
+ def generate_client_method(
48
+ self,
49
+ method_def: ast.FunctionDef | ast.AsyncFunctionDef,
50
+ operation_definition: OperationDefinitionNode,
51
+ ) -> ast.FunctionDef | ast.AsyncFunctionDef:
52
+ annotations: dict[str, str] = collect_annotations(
53
+ method_def, operation_definition
54
+ )
55
+ spec = extract_entry_spec(operation_definition, annotations)
56
+ if spec is not None:
57
+ self.specs.append(spec)
58
+ if (
59
+ operation_definition.name
60
+ and operation_definition.name.value == ADD_LEDGER_ENTRIES_OPERATION
61
+ ):
62
+ self.saw_batch_operation = True
63
+ self._widen_entries_argument(method_def)
64
+ return method_def
65
+
66
+ def _widen_entries_argument(
67
+ self, method_def: ast.FunctionDef | ast.AsyncFunctionDef
68
+ ) -> None:
69
+ """Let `add_ledger_entries` take typed entries as well as raw inputs.
70
+
71
+ ariadne annotates the argument `list[AddLedgerEntryInput]`, which typed
72
+ entries satisfy at runtime but not under a type checker. Widening to a
73
+ `Sequence` of either keeps raw inputs working while accepting typed
74
+ models directly -- `Sequence` because `list` is invariant, so
75
+ `list[AuthCapture]` would otherwise be rejected.
76
+
77
+ Widening the annotation alone would be a runtime trap. The base client
78
+ recurses into variables with `isinstance(value, list)`, so a tuple would
79
+ satisfy the annotation, skip conversion, and reach `json.dumps` as model
80
+ objects. `_coerce_entries_to_list` closes that.
81
+ """
82
+ for arg in method_def.args.args:
83
+ if arg.arg != ENTRIES_ARGUMENT:
84
+ continue
85
+ # Parsed rather than hand-built: an ast.Name whose id is an entire
86
+ # expression unparses fine but is not a valid tree, so anything that
87
+ # visits or compiles it breaks.
88
+ arg.annotation = ast.parse(
89
+ "Sequence[Union[AddLedgerEntryInput, TypedLedgerEntry]]",
90
+ mode="eval",
91
+ ).body
92
+ self._coerce_entries_to_list(method_def)
93
+ self.widened_entries_argument = True
94
+ return
95
+
96
+ console_log.warning(
97
+ "Could not find an %r argument on the generated add_ledger_entries "
98
+ "method, so its signature was left as-is. Typed entry payloads will "
99
+ "still serialise correctly but will not typecheck when passed to it.",
100
+ ENTRIES_ARGUMENT,
101
+ )
102
+
103
+ def _coerce_entries_to_list(
104
+ self, method_def: ast.FunctionDef | ast.AsyncFunctionDef
105
+ ) -> None:
106
+ """Rewrite `{"entries": entries}` to `{"entries": list(entries)}`.
107
+
108
+ The base client only recurses into `list`, so any other sequence reaches
109
+ the JSON encoder holding model objects. Widening the annotation is what
110
+ makes that reachable, so the coercion belongs with it.
111
+ """
112
+ for node in ast.walk(method_def):
113
+ if not isinstance(node, ast.Dict):
114
+ continue
115
+ for index, key in enumerate(node.keys):
116
+ if not (
117
+ isinstance(key, ast.Constant) and key.value == ENTRIES_ARGUMENT
118
+ ):
119
+ continue
120
+ value = node.values[index]
121
+ if isinstance(value, ast.Name) and value.id == ENTRIES_ARGUMENT:
122
+ node.values[index] = ast.Call(
123
+ func=ast.Name(id="list", ctx=ast.Load()),
124
+ args=[value],
125
+ keywords=[],
126
+ )
127
+ return
128
+
129
+ console_log.warning(
130
+ "Could not find the %r variables assignment in add_ledger_entries, so "
131
+ "it was left as-is. Passing a non-list sequence of entries will fail "
132
+ "to serialise.",
133
+ ENTRIES_ARGUMENT,
134
+ )
135
+
136
+ def generate_client_code(self, generated_code: str) -> str:
137
+ if not self.widened_entries_argument:
138
+ return generated_code
139
+ return self._insert_imports(
140
+ generated_code,
141
+ [
142
+ "from typing import Sequence",
143
+ f"from .{MODULE_NAME} import TypedLedgerEntry",
144
+ ],
145
+ )
146
+
147
+ @staticmethod
148
+ def _insert_imports(code: str, imports: list[str]) -> str:
149
+ """Insert imports after the module's existing top-level import block."""
150
+ lines = code.splitlines()
151
+ last_import_line = 0
152
+ for node in ast.parse(code).body:
153
+ if isinstance(node, (ast.Import, ast.ImportFrom)):
154
+ last_import_line = max(last_import_line, node.end_lineno or 0)
155
+ lines[last_import_line:last_import_line] = imports
156
+ return "\n".join(lines) + "\n"
157
+
158
+ def generate_init_code(self, generated_code: str) -> str:
159
+ if self.specs and not self.saw_batch_operation:
160
+ # Typed payloads exist but no batch operation was generated to take
161
+ # them. Silence here would leave users with models nothing accepts.
162
+ console_log.warning(
163
+ "Generated %d typed entry payload(s) but found no %r operation, "
164
+ "so no batch method accepts them. Has the operation been renamed "
165
+ "upstream?",
166
+ len(self.specs),
167
+ ADD_LEDGER_ENTRIES_OPERATION,
168
+ )
169
+ module_path = self.package_path / f"{MODULE_NAME}.py"
170
+ module_path.parent.mkdir(parents=True, exist_ok=True)
171
+ module_path.write_text(
172
+ self._add_comment(render_module(self.specs)), encoding="utf-8"
173
+ )
174
+ return generated_code + self._init_additions()
175
+
176
+ def _add_comment(self, code: str) -> str:
177
+ # This module is written directly rather than through ariadne's module
178
+ # pipeline, so it does not pass through the GenerateFileComment hook.
179
+ queries_path = (
180
+ self.config_dict.get("tool", {})
181
+ .get("ariadne-codegen", {})
182
+ .get("queries_path", "")
183
+ )
184
+ comment = "# Generated by fragment (with the help of ariadne-codegen)"
185
+ if queries_path:
186
+ comment += f"\n# Source: {queries_path}"
187
+ return f"{comment}\n\n{code}"
188
+
189
+ def _init_additions(self) -> str:
190
+ """Re-export the typed models and extend `__all__`.
191
+
192
+ Resolves names itself; `resolve_class_names` being pure is what makes this
193
+ agree with the renderer without depending on hook order.
194
+ """
195
+ names = ["TypedLedgerEntry", "to_entry_inputs"] + [
196
+ class_name for class_name, _ in resolve_class_names(self.specs)
197
+ ]
198
+ names.sort()
199
+ imported = ",\n ".join(names)
200
+ exported = "\n".join(f' "{name}",' for name in names)
201
+ return (
202
+ f"\nfrom .{MODULE_NAME} import (\n {imported},\n)\n"
203
+ f"\n__all__ += [\n{exported}\n]\n"
204
+ )