apppy-fastql 0.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.
@@ -0,0 +1,28 @@
1
+ __generated__/
2
+ dist/
3
+ *.egg-info
4
+ .env
5
+ .env.*
6
+ *.env
7
+ !.env.ci
8
+ .file_store/
9
+ *.pid
10
+ .python-version
11
+ *.secrets
12
+ .secrets
13
+ *.tar.gz
14
+ *.test_output/
15
+ .test_output/
16
+ uv.lock
17
+ *.whl
18
+
19
+ # System files
20
+ __pycache__
21
+ .DS_Store
22
+
23
+ # Editor files
24
+ *.sublime-project
25
+ *.sublime-workspace
26
+ .vscode/*
27
+ !.vscode/settings.json
28
+
@@ -0,0 +1,15 @@
1
+ Metadata-Version: 2.4
2
+ Name: apppy-fastql
3
+ Version: 0.1.0
4
+ Summary: Annotations and types to support GraphQL for server development
5
+ Project-URL: Homepage, https://github.com/spals/apppy
6
+ Author: Tim Kral
7
+ License: MIT
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Classifier: Programming Language :: Python :: 3
10
+ Requires-Python: >=3.11
11
+ Requires-Dist: apppy-env>=0.1.0
12
+ Requires-Dist: apppy-logger>=0.1.0
13
+ Requires-Dist: inflection==0.5.1
14
+ Requires-Dist: sqids==0.5.0
15
+ Requires-Dist: strawberry-graphql[fastapi]==0.275.5
File without changes
@@ -0,0 +1,23 @@
1
+ ifndef APPPY_FASTQL_MK_INCLUDED
2
+ APPPY_FASTQL_MK_INCLUDED := 1
3
+ FASTQL_PKG_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
4
+
5
+ .PHONY: fastql fastql-dev fastql/build fastql/clean fastql/install fastql/install-dev
6
+
7
+ fastql: fastql/clean fastql/install
8
+
9
+ fastql-dev: fastql/clean fastql/install-dev
10
+
11
+ fastql/build:
12
+ cd $(FASTQL_PKG_DIR) && uvx --from build pyproject-build
13
+
14
+ fastql/clean:
15
+ cd $(FASTQL_PKG_DIR) && rm -rf dist/ *.egg-info .venv
16
+
17
+ fastql/install: fastql/build
18
+ cd $(FASTQL_PKG_DIR) && uv pip install dist/*.whl
19
+
20
+ fastql/install-dev:
21
+ cd $(FASTQL_PKG_DIR) && uv pip install -e .
22
+
23
+ endif # APPPY_FASTQL_MK_INCLUDED
@@ -0,0 +1,29 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.25"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "apppy-fastql"
7
+ version = "0.1.0"
8
+ description = "Annotations and types to support GraphQL for server development"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = {text = "MIT"}
12
+ authors = [{ name = "Tim Kral" }]
13
+ classifiers = [
14
+ "Programming Language :: Python :: 3",
15
+ "License :: OSI Approved :: MIT License",
16
+ ]
17
+ dependencies = [
18
+ "apppy-env>=0.1.0",
19
+ "apppy-logger>=0.1.0",
20
+ "inflection==0.5.1",
21
+ "strawberry-graphql[fastapi]==0.275.5",
22
+ "sqids==0.5.0",
23
+ ]
24
+
25
+ [project.urls]
26
+ Homepage = "https://github.com/spals/apppy"
27
+
28
+ [tool.hatch.build.targets.wheel]
29
+ packages = ["src/apppy"]
@@ -0,0 +1,681 @@
1
+ import inspect
2
+ from collections.abc import Awaitable, Callable
3
+ from functools import wraps
4
+ from pathlib import Path
5
+ from types import UnionType
6
+ from typing import ( # noqa: UP035
7
+ Annotated,
8
+ Any,
9
+ List,
10
+ Optional,
11
+ Union,
12
+ cast,
13
+ get_args,
14
+ get_origin,
15
+ get_type_hints,
16
+ )
17
+
18
+ import inflection
19
+ import strawberry
20
+ from strawberry.annotation import StrawberryAnnotation
21
+ from strawberry.fastapi import GraphQLRouter
22
+ from strawberry.http.ides import GraphQL_IDE
23
+ from strawberry.printer import print_schema
24
+ from strawberry.printer.printer import PrintExtras, print_args
25
+ from strawberry.types.field import StrawberryField, StrawberryUnion
26
+ from strawberry.types.fields.resolver import StrawberryResolver
27
+
28
+ from apppy.fastql.annotation.error import valid_fastql_type_error
29
+ from apppy.fastql.annotation.input import valid_fastql_type_input
30
+ from apppy.fastql.annotation.output import extract_concrete_type, valid_fastql_type_output
31
+ from apppy.logger import WithLogger
32
+
33
+
34
+ class FastQL(WithLogger):
35
+ """
36
+ A convenience class to support IOC with respect to graphql.
37
+
38
+ Its include_mutation and include_query can be used in a similar
39
+ way to FastAPI's include_router.
40
+ """
41
+
42
+ def __init__(self):
43
+ self._mutations = []
44
+ self._queries = []
45
+ self._types_error = []
46
+ self._types_id = []
47
+ self._types_input = []
48
+ self._types_output = []
49
+
50
+ self._graphql_schema: strawberry.Schema | None = None
51
+
52
+ ##### ##### ##### Build Schema ##### ##### #####
53
+
54
+ def _attach_fields_to_namespace(
55
+ self, resolvers: dict[str, Callable[..., Any]], namespace: dict[str, Any]
56
+ ) -> None:
57
+ for attr_name, resolver in resolvers.items():
58
+ resolver_name = getattr(resolver, "__name__", "<unknown>")
59
+ return_type = resolver._fastql_return_type # type: ignore[attr-defined]
60
+ error_types = resolver._fastql_error_types # type: ignore[attr-defined]
61
+ permission_instances = resolver._fastql_permission_instances # type: ignore[attr-defined]
62
+ skip_permission_checks = resolver._skip_permission_checks # type: ignore[attr-defined]
63
+
64
+ if not valid_fastql_type_output(return_type):
65
+ return_type_name = getattr(return_type, "__name__", "<unknown>")
66
+ raise TypeError(
67
+ f"Return type {return_type_name} for {resolver_name} must be a valid @fastql_type_output type." # noqa: E501
68
+ )
69
+ self._register_type_output(resolver._fastql_return_type) # type: ignore[attr-defined]
70
+
71
+ sig = inspect.signature(resolver)
72
+ for param in sig.parameters.values():
73
+ if param.name in {"self", "info"}:
74
+ continue
75
+ if param.annotation is inspect.Parameter.empty:
76
+ continue
77
+ if not valid_fastql_type_input(param.annotation):
78
+ raise TypeError(
79
+ f"Parameter {param.annotation.__name__} of {resolver_name} must be a valid @fastql_type_input type." # noqa: E501
80
+ )
81
+ self._register_type_input(param.annotation)
82
+
83
+ if error_types:
84
+ for error_type in error_types:
85
+ if not valid_fastql_type_error(error_type):
86
+ raise TypeError(
87
+ f"Error type of {resolver_name} must be a valid @fastql_type_error type." # noqa: E501
88
+ )
89
+ self._register_type_error(error_type)
90
+
91
+ union_name_python = f"{attr_name}_result"
92
+ union_name = "".join(word.capitalize() for word in union_name_python.split("_"))
93
+ result_union = Annotated[
94
+ Union[return_type, *error_types], strawberry.union(union_name) # type: ignore[valid-type]
95
+ ]
96
+
97
+ @wraps(resolver) # type: ignore[arg-type]
98
+ async def wrapped_resolver(
99
+ *args,
100
+ __resolver=resolver,
101
+ __error_types=error_types,
102
+ __permission_instances=permission_instances,
103
+ __skip_permission_checks=skip_permission_checks,
104
+ **kwargs,
105
+ ):
106
+ try:
107
+ info = kwargs["info"]
108
+ if not __skip_permission_checks:
109
+ for permission in __permission_instances:
110
+ if not permission.has_permission(None, info):
111
+ return permission.graphql_client_error_class(
112
+ *permission.graphql_client_error_args()
113
+ )
114
+ return await __resolver(*args, **kwargs)
115
+ except __error_types as e:
116
+ return e
117
+
118
+ typed_resolver = cast(Callable[..., Any], wrapped_resolver)
119
+ namespace[attr_name] = StrawberryField(
120
+ graphql_name=inflection.camelize(attr_name, uppercase_first_letter=False),
121
+ python_name=attr_name,
122
+ base_resolver=StrawberryResolver(typed_resolver),
123
+ type_annotation=StrawberryAnnotation(result_union),
124
+ is_subscription=False,
125
+ )
126
+ else:
127
+ namespace[attr_name] = strawberry.field(
128
+ resolver,
129
+ name=inflection.camelize(attr_name, uppercase_first_letter=False),
130
+ )
131
+
132
+ def _compose_mutations(self, name: str = "Mutation") -> Any:
133
+ namespace: dict[str, Any] = {}
134
+
135
+ for instance in self._mutations:
136
+ cls = type(instance)
137
+ if not getattr(cls, "_fastql_is_mutation", False):
138
+ continue
139
+
140
+ resolvers = self._extract_fastql_resolvers(
141
+ instance, cls, expected_decorator_flag="_fastql_is_mutation"
142
+ )
143
+ self._attach_fields_to_namespace(resolvers, namespace)
144
+
145
+ return strawberry.type(type(name, (), namespace))
146
+
147
+ def _compose_queries(self, name: str = "Query") -> Any:
148
+ namespace: dict[str, Any] = {}
149
+
150
+ for instance in self._queries:
151
+ cls = type(instance)
152
+ if not getattr(cls, "_fastql_is_query", False):
153
+ continue
154
+
155
+ resolvers = self._extract_fastql_resolvers(
156
+ instance, cls, expected_decorator_flag="_fastql_is_query"
157
+ )
158
+
159
+ self._attach_fields_to_namespace(resolvers, namespace)
160
+
161
+ return strawberry.type(type(name, (), namespace))
162
+
163
+ def _extract_fastql_resolvers(
164
+ self,
165
+ instance: Any,
166
+ cls: type,
167
+ expected_decorator_flag: str,
168
+ ) -> dict[str, Callable[..., Any]]:
169
+ """
170
+ Returns a mapping of field name -> callable resolver that matches
171
+ the expected fastql_query_field or fastql_mutation_field decorators.
172
+ Raises if mismatched decorators are detected.
173
+ """
174
+ resolvers: dict[str, Callable[..., Any]] = {}
175
+
176
+ for attr_name in dir(instance):
177
+ if attr_name.startswith("_"):
178
+ continue
179
+
180
+ attr = getattr(instance, attr_name)
181
+
182
+ if isinstance(attr, StrawberryField):
183
+ base = attr.base_resolver
184
+ if isinstance(base, StrawberryResolver):
185
+ resolver = base.wrapped_func.__get__(instance, cls)
186
+ else:
187
+ resolver = base
188
+ else:
189
+ resolver = attr
190
+
191
+ if not callable(resolver):
192
+ continue
193
+
194
+ # Validate that the method is marked as a mutation or query
195
+ is_query_field = hasattr(resolver, "_fastql_query_field")
196
+ is_mutation_field = hasattr(resolver, "_fastql_mutation_field")
197
+
198
+ if expected_decorator_flag == "_fastql_is_query" and is_mutation_field:
199
+ raise TypeError(
200
+ f"{cls.__name__}.{attr_name} is marked as a mutation field inside a query class"
201
+ )
202
+ if expected_decorator_flag == "_fastql_is_mutation" and is_query_field:
203
+ raise TypeError(
204
+ f"{cls.__name__}.{attr_name} is marked as a query field inside a mutation class"
205
+ )
206
+ # Validate that we have an acceptable return/output type
207
+ return_type = hasattr(resolver, "_fastql_return_type")
208
+ if return_type is None:
209
+ raise TypeError(
210
+ f"{cls.__name__} is will be included in fastql schema but has no return type"
211
+ )
212
+
213
+ resolvers[attr_name] = resolver
214
+
215
+ if not resolvers:
216
+ kind = "query" if expected_decorator_flag == "_fastql_is_query" else "mutation"
217
+ raise ValueError(
218
+ f"{cls.__name__} is marked as a {kind} class but defines no valid {kind} fields."
219
+ )
220
+
221
+ return resolvers
222
+
223
+ def _include_mutation(self, mutation_instance: Any) -> None:
224
+ self._mutations.append(mutation_instance)
225
+
226
+ def _include_query(self, query_instance: Any) -> None:
227
+ self._queries.append(query_instance)
228
+
229
+ def _register_type_error(self, error_type: Any) -> None:
230
+ if error_type in self._types_error:
231
+ return
232
+
233
+ if not hasattr(error_type, "_fastql_type_error"):
234
+ return
235
+
236
+ self._logger.info(
237
+ "Registering FastQL error type", extra={"error_type": error_type.__name__}
238
+ )
239
+ self._types_error.append(error_type)
240
+
241
+ def _register_type_id(self, id_type: Any) -> None:
242
+ if id_type in self._types_id:
243
+ return
244
+
245
+ if not hasattr(id_type, "_fastql_type_id"):
246
+ return
247
+
248
+ self._logger.info("Registering FastQL id type", extra={"id_type": id_type.__name__})
249
+ self._types_id.append(id_type)
250
+
251
+ def _register_type_input(self, input_type: Any) -> None:
252
+ if input_type in self._types_input:
253
+ return
254
+
255
+ # Special case: Register any typed id on the
256
+ # input type.
257
+ if hasattr(input_type, "_fastql_type_id"):
258
+ self._register_type_id(input_type)
259
+ return
260
+
261
+ if not hasattr(input_type, "_fastql_type_input"):
262
+ return
263
+
264
+ self._logger.info(
265
+ "Registering FastQL intput type", extra={"input_type": input_type.__name__}
266
+ )
267
+ self._types_input.append(input_type)
268
+
269
+ # Recursively check field types
270
+ for field_type in getattr(input_type, "__annotations__", {}).values():
271
+ self._register_type_input(field_type)
272
+
273
+ def _register_type_output(self, output_type: Any) -> None:
274
+ output_type = extract_concrete_type(output_type)
275
+ if output_type in self._types_output:
276
+ return
277
+
278
+ # Special case: Register any typed id on the
279
+ # output type.
280
+ if hasattr(output_type, "_fastql_type_id"):
281
+ self._register_type_id(output_type)
282
+ return
283
+
284
+ if not hasattr(output_type, "_fastql_type_output"):
285
+ return
286
+
287
+ self._logger.info(
288
+ "Registering FastQL output type", extra={"output_type": output_type.__name__}
289
+ )
290
+ self._types_output.append(output_type)
291
+
292
+ # Recursively check field types
293
+ for field_type in getattr(output_type, "__annotations__", {}).values():
294
+ self._register_type_output(field_type)
295
+
296
+ def extract_mutation_field_metadata(
297
+ self,
298
+ instance: Any,
299
+ ) -> dict[str, Any] | None:
300
+ """
301
+ Returns a mapping of field name -> field resolver
302
+ """
303
+ cls = type(instance)
304
+ if not getattr(cls, "_fastql_is_mutation", False):
305
+ return None
306
+
307
+ resolvers = self._extract_fastql_resolvers(
308
+ instance, cls, expected_decorator_flag="_fastql_is_mutation"
309
+ )
310
+
311
+ mutation_metadata: dict[str, Any] = {}
312
+ self._attach_fields_to_namespace(resolvers=resolvers, namespace=mutation_metadata)
313
+
314
+ return mutation_metadata
315
+
316
+ def extract_query_field_metadata(
317
+ self,
318
+ instance: Any,
319
+ ) -> dict[str, Any] | None:
320
+ """
321
+ Returns a mapping of field name -> field resolver
322
+ """
323
+ cls = type(instance)
324
+ if not getattr(cls, "_fastql_is_query", False):
325
+ return None
326
+
327
+ resolvers = self._extract_fastql_resolvers(
328
+ instance, cls, expected_decorator_flag="_fastql_is_query"
329
+ )
330
+
331
+ query_metadata: dict[str, Any] = {}
332
+ self._attach_fields_to_namespace(resolvers=resolvers, namespace=query_metadata)
333
+
334
+ return query_metadata
335
+
336
+ def include_in_schema(self, instance: Any) -> None:
337
+ cls = type(instance)
338
+ if getattr(cls, "_fastql_is_query", False):
339
+ self._include_query(instance)
340
+ elif getattr(cls, "_fastql_is_mutation", False):
341
+ self._include_mutation(instance)
342
+ else:
343
+ self._logger.critical(
344
+ "Query or mutation is missing fastql decorator. Please add @fastql_query() or "
345
+ + "@fastql_mutation() to the class.",
346
+ stack_info=True,
347
+ extra={"cls": cls.__name__},
348
+ )
349
+ raise TypeError(
350
+ "Query or mutation is missing fastql decorator. Please add @fastql_query() or "
351
+ + f"@fastql_mutation() to the class: {cls.__name__}"
352
+ )
353
+
354
+ ##### ##### ##### Properties and Runtime ##### ##### #####
355
+
356
+ def create_router(
357
+ self,
358
+ context_getter: Callable[..., Any | None | Awaitable[Any | None]],
359
+ graphiql: bool,
360
+ ) -> GraphQLRouter:
361
+ graphql_ide: GraphQL_IDE | None = "graphiql" if graphiql else None
362
+ return GraphQLRouter(
363
+ schema=self.schema,
364
+ path="/graphql",
365
+ context_getter=context_getter,
366
+ graphql_ide=graphql_ide,
367
+ )
368
+
369
+ @property
370
+ def all_types(self) -> list[type]:
371
+ return sorted(
372
+ set(self._types_error + self._types_id + self._types_input + self._types_output),
373
+ key=lambda cls: cls.__name__,
374
+ )
375
+
376
+ @property
377
+ def all_types_map(self) -> dict[str, type]:
378
+ return {cls.__name__: cls for cls in self.all_types}
379
+
380
+ @property
381
+ def mutations_raw(self) -> list[Any]:
382
+ return self._mutations
383
+
384
+ @property
385
+ def mutations_map(self) -> dict[str, type]:
386
+ m_map: dict[str, type] = {}
387
+ for m in self._mutations:
388
+ cls = type(m)
389
+ m_map[cls.__name__] = m
390
+
391
+ return m_map
392
+
393
+ @property
394
+ def queries_raw(self) -> list[Any]:
395
+ return self._queries
396
+
397
+ @property
398
+ def queries_map(self) -> dict[str, type]:
399
+ q_map: dict[str, type] = {}
400
+ for q in self._queries:
401
+ cls = type(q)
402
+ q_map[cls.__name__] = q
403
+
404
+ return q_map
405
+
406
+ @property
407
+ def schema(self) -> strawberry.Schema:
408
+ if self._graphql_schema is None:
409
+ self._graphql_schema = strawberry.Schema(
410
+ query=self._compose_queries(),
411
+ mutation=self._compose_mutations(),
412
+ )
413
+
414
+ return self._graphql_schema
415
+
416
+ @property
417
+ def types_error_metadata(self) -> list[tuple[str, list[str]]]:
418
+ """
419
+ Returns a list of (error_type_name, field_names) for each error type.
420
+ """
421
+ result: list[tuple[str, list[str]]] = []
422
+ for cls in self._types_error:
423
+ try:
424
+ annotations = get_type_hints(cls)
425
+ except Exception:
426
+ annotations = getattr(cls, "__annotations__", {})
427
+ result.append((cls.__name__, list(annotations.keys())))
428
+
429
+ result.sort(key=lambda item: item[0])
430
+ return result
431
+
432
+ @property
433
+ def types_error_raw(self) -> list[type]:
434
+ return self._types_error
435
+
436
+ @property
437
+ def types_id_raw(self) -> list[type]:
438
+ return self._types_id
439
+
440
+ @property
441
+ def types_id_metadata(self) -> list[str]:
442
+ return sorted(cls.__name__ for cls in self._types_id)
443
+
444
+ @property
445
+ def types_input_metadata(self) -> list[tuple[str, list[str]]]:
446
+ result = []
447
+ for cls in self._types_input:
448
+ try:
449
+ annotations = get_type_hints(cls)
450
+ except Exception:
451
+ annotations = getattr(cls, "__annotations__", {})
452
+ result.append((cls.__name__, list(annotations.keys())))
453
+ result.sort(key=lambda item: item[0])
454
+ return result
455
+
456
+ @property
457
+ def types_input_raw(self) -> list[type]:
458
+ return self._types_input
459
+
460
+ @property
461
+ def types_output_metadata(self) -> list[tuple[str, list[str]]]:
462
+ """
463
+ Returns a list of (type_name, field_names) for each output type.
464
+ Useful for generating GraphQL fragments.
465
+ """
466
+ result: list[tuple[str, list[str]]] = []
467
+ for cls in self._types_output:
468
+ try:
469
+ annotations = get_type_hints(cls)
470
+ except Exception:
471
+ annotations = getattr(cls, "__annotations__", {}) # fallback
472
+ result.append((cls.__name__, list(annotations.keys())))
473
+
474
+ result.sort(key=lambda item: item[0]) # sort by type name
475
+ return result
476
+
477
+ @property
478
+ def types_output_raw(self) -> list[type]:
479
+ return self._types_output
480
+
481
+ ##### ##### ##### Codegen ##### ##### #####
482
+
483
+ def collect_and_print_fragments(
484
+ self,
485
+ typename: str,
486
+ visited: set[str],
487
+ fragments: dict[str, str],
488
+ ) -> None:
489
+ if typename in visited:
490
+ return
491
+
492
+ visited.add(typename)
493
+ cls = self.all_types_map.get(typename)
494
+ if not cls:
495
+ return
496
+
497
+ try:
498
+ annotations = get_type_hints(cls)
499
+ except Exception:
500
+ annotations = getattr(cls, "__annotations__", {})
501
+
502
+ lines = [f"fragment {typename} on {typename} {{"]
503
+
504
+ for field_name, field_type in annotations.items():
505
+ field_name_camel = inflection.camelize(field_name, uppercase_first_letter=False)
506
+ nested_type = None
507
+
508
+ origin = get_origin(field_type)
509
+ args = get_args(field_type)
510
+
511
+ # CASE: Optional and Union[X, Y, None] and X | Y
512
+ if origin in (Optional, Union, UnionType):
513
+ for arg in args:
514
+ arg_name = getattr(arg, "__name__", None)
515
+ if arg_name and arg_name in self.all_types_map:
516
+ nested_type = arg_name
517
+ break
518
+ # CASE: List[X] and list[X]
519
+ elif origin in (list, List): # noqa: UP006
520
+ (inner_type,) = args
521
+ inner_type_name = getattr(inner_type, "__name__", None)
522
+ if inner_type_name and inner_type_name in self.all_types_map:
523
+ nested_type = inner_type_name
524
+ # Recurse into the list's inner type
525
+ self.collect_and_print_fragments(nested_type, visited, fragments)
526
+ lines.append(f" {field_name_camel} {{")
527
+ lines.append(f" ...{nested_type}")
528
+ lines.append(" }")
529
+ continue
530
+ elif hasattr(field_type, "__name__") and field_type.__name__ in self.all_types_map:
531
+ nested_type = field_type.__name__
532
+
533
+ if nested_type:
534
+ # Recurse first so nested fragments are available
535
+ self.collect_and_print_fragments(nested_type, visited, fragments)
536
+ lines.append(f" {field_name_camel} {{")
537
+ lines.append(f" ...{nested_type}")
538
+ lines.append(" }")
539
+ else:
540
+ lines.append(f" {field_name_camel}")
541
+
542
+ lines.append("}")
543
+ fragments[typename] = "\n".join(lines)
544
+
545
+ def collect_and_print_mutations(
546
+ self,
547
+ mutation_class_name: str,
548
+ visited: set[str],
549
+ mutations: dict[str, str],
550
+ ) -> None:
551
+ if mutation_class_name in visited:
552
+ return
553
+
554
+ visited.add(mutation_class_name)
555
+
556
+ m = self.mutations_map[mutation_class_name]
557
+ if m is None:
558
+ return
559
+
560
+ schema = self.schema
561
+ m_field_metadata = self.extract_mutation_field_metadata(m)
562
+ if m_field_metadata is None:
563
+ # This should not happen
564
+ # Very edge case in which we pass in a
565
+ # non-mutation instance
566
+ return
567
+
568
+ for _, m_metadata in m_field_metadata.items():
569
+ if isinstance(m_metadata, StrawberryField):
570
+ m_name = f"{m_metadata.graphql_name}Mutation"
571
+ m_name = m_name[0].upper() + m_name[1:]
572
+ m_args = {
573
+ f"${arg.python_name}": schema.schema_converter.from_argument(arg)
574
+ for arg in m_metadata.arguments
575
+ }
576
+ lines = [
577
+ f"mutation {m_name}{print_args(m_args, schema=schema, extras=PrintExtras())} {{"
578
+ ]
579
+
580
+ gql_name = m_metadata.graphql_name
581
+ gql_args = [
582
+ f"{m_arg_name.strip('$')}: {m_arg_name}" for m_arg_name, _ in m_args.items()
583
+ ]
584
+ gql_args_str = f"({','.join(gql_args)})" if len(gql_args) > 0 else ""
585
+ lines.append(f" {gql_name}{gql_args_str} {{")
586
+
587
+ if isinstance(m_metadata.type, StrawberryUnion):
588
+ for gql_type_annotation in m_metadata.type.type_annotations:
589
+ gql_result_type = gql_type_annotation.evaluate()
590
+ gql_result_type_name = gql_result_type.__name__
591
+ lines.append(f" ... on {gql_result_type_name} {{")
592
+ lines.append(f" ... {gql_result_type_name}")
593
+ lines.append(" }")
594
+ else:
595
+ gql_result_type = m_metadata.type # type: ignore[assignment]
596
+ gql_result_type_name = gql_result_type.__name__
597
+ lines.append(f" ... on {gql_result_type_name} {{")
598
+ lines.append(f" ... {gql_result_type_name}")
599
+ lines.append(" }")
600
+
601
+ lines.append(" __typename")
602
+ lines.append(" }")
603
+ lines.append("}")
604
+
605
+ mutations[m_name] = "\n".join(lines)
606
+
607
+ def collect_and_print_queries(
608
+ self,
609
+ query_class_name: str,
610
+ visited: set[str],
611
+ queries: dict[str, str],
612
+ ) -> None:
613
+ if query_class_name in visited:
614
+ return
615
+
616
+ visited.add(query_class_name)
617
+
618
+ q = self.queries_map[query_class_name]
619
+ if q is None:
620
+ return
621
+
622
+ schema = self.schema
623
+ q_field_metadata = self.extract_query_field_metadata(q)
624
+ if q_field_metadata is None:
625
+ # This should not happen
626
+ # Very edge case in which we pass in a
627
+ # non-query instance
628
+ return
629
+
630
+ for _, q_metadata in q_field_metadata.items():
631
+ if isinstance(q_metadata, StrawberryField):
632
+ q_name = f"{q_metadata.graphql_name}Query"
633
+ q_name = q_name[0].upper() + q_name[1:]
634
+ q_args = {
635
+ f"${arg.python_name}": schema.schema_converter.from_argument(arg)
636
+ for arg in q_metadata.arguments
637
+ }
638
+ lines = [
639
+ f"query {q_name}{print_args(q_args, schema=schema, extras=PrintExtras())} {{"
640
+ ]
641
+
642
+ gql_name = q_metadata.graphql_name
643
+ gql_args = [
644
+ f"{q_arg_name.strip('$')}: {q_arg_name}" for q_arg_name, _ in q_args.items()
645
+ ]
646
+ gql_args_str = f"({','.join(gql_args)})" if len(gql_args) > 0 else ""
647
+ lines.append(f" {gql_name}{gql_args_str} {{")
648
+
649
+ if isinstance(q_metadata.type, StrawberryUnion):
650
+ for gql_type_annotation in q_metadata.type.type_annotations:
651
+ gql_result_type = gql_type_annotation.evaluate()
652
+ gql_result_type_name = gql_result_type.__name__
653
+ lines.append(f" ... on {gql_result_type_name} {{")
654
+ lines.append(f" ... {gql_result_type_name}")
655
+ lines.append(" }")
656
+ else:
657
+ gql_result_type = q_metadata.type # type: ignore[assignment]
658
+ gql_result_type_name = gql_result_type.__name__
659
+ lines.append(f" ... on {gql_result_type_name} {{")
660
+ lines.append(f" ... {gql_result_type_name}")
661
+ lines.append(" }")
662
+
663
+ lines.append(" __typename")
664
+ lines.append(" }")
665
+ lines.append("}")
666
+
667
+ queries[q_name] = "\n".join(lines)
668
+
669
+ def print_schema(self) -> str:
670
+ return print_schema(self.schema)
671
+
672
+ def write_graphql_file(
673
+ self, base_dir: str, file_name: str, file_content: str, file_header: str | None = None
674
+ ) -> None:
675
+ graphql_path = Path(base_dir, file_name)
676
+ graphql_path.parent.mkdir(parents=True, exist_ok=True)
677
+
678
+ with open(graphql_path, "w") as f:
679
+ if file_header is not None:
680
+ f.write(file_header)
681
+ f.write(file_content)
@@ -0,0 +1,7 @@
1
+ from apppy.fastql.annotation.error import fastql_type_error # noqa: F401
2
+ from apppy.fastql.annotation.id import fastql_type_id # noqa: F401
3
+ from apppy.fastql.annotation.input import fastql_type_input # noqa: F401
4
+ from apppy.fastql.annotation.interface import fastql_type_interface # noqa: F401
5
+ from apppy.fastql.annotation.mutation import fastql_mutation, fastql_mutation_field # noqa: F401
6
+ from apppy.fastql.annotation.output import fastql_type_output # noqa: F401
7
+ from apppy.fastql.annotation.query import fastql_query, fastql_query_field # noqa: F401
@@ -0,0 +1,15 @@
1
+ from typing import Any
2
+
3
+ import strawberry
4
+
5
+
6
+ def fastql_type_error(cls: type[Any]):
7
+ """
8
+ Custom field decorator that marks error types (i.e. response errors from APIs)
9
+ """
10
+ cls._fastql_type_error = True # type: ignore[attr-defined]
11
+ return strawberry.type(cls)
12
+
13
+
14
+ def valid_fastql_type_error(cls: Any) -> bool:
15
+ return hasattr(cls, "_fastql_type_error")
@@ -0,0 +1,18 @@
1
+ import strawberry
2
+
3
+ from apppy.fastql.typed_id import TypedId
4
+
5
+
6
+ def fastql_type_id(cls: type[TypedId]):
7
+ """
8
+ Decorator for TypedId subclasses that automatically registers them as GraphQL scalars.
9
+ """
10
+ if not issubclass(cls, TypedId):
11
+ raise TypeError(f"{cls.__name__} must subclass TypedId to use @fastql_type_id")
12
+
13
+ cls._fastql_type_id = True # type: ignore[attr-defined]
14
+
15
+ return strawberry.scalar(
16
+ serialize=lambda value: str(value),
17
+ parse_value=lambda raw: cls.from_str(raw),
18
+ )(cls)
@@ -0,0 +1,15 @@
1
+ from typing import Any
2
+
3
+ import strawberry
4
+
5
+
6
+ def fastql_type_input(cls: type[Any]):
7
+ """
8
+ Custom field decorator that marks input types (i.e. request types in APIs)
9
+ """
10
+ cls._fastql_type_input = True # type: ignore[attr-defined]
11
+ return strawberry.input(cls)
12
+
13
+
14
+ def valid_fastql_type_input(cls: Any) -> bool:
15
+ return hasattr(cls, "_fastql_type_input")
@@ -0,0 +1,15 @@
1
+ from typing import Any
2
+
3
+ import strawberry
4
+
5
+
6
+ def fastql_type_interface(cls: type[Any]):
7
+ """
8
+ Decorator to wrap strawberry.interface
9
+ """
10
+ cls._fastql_type_interface = True # type: ignore[attr-defined]
11
+ return strawberry.interface(cls)
12
+
13
+
14
+ def valid_fastql_type_interface(cls: type) -> bool:
15
+ return hasattr(cls, "_fastql_type_interface")
@@ -0,0 +1,84 @@
1
+ from collections.abc import Callable, Sequence
2
+ from typing import get_type_hints
3
+
4
+ from apppy.fastql.annotation.output import valid_fastql_type_output
5
+ from apppy.fastql.errors import GraphQLError
6
+ from apppy.fastql.permissions import GraphQLPermission
7
+
8
+
9
+ def fastql_mutation():
10
+ """
11
+ Custom class decorator to mark mutation containers for FastQL schema composition.
12
+ """
13
+
14
+ def decorator(cls):
15
+ cls._fastql_is_mutation = True # type: ignore[attr-defined]
16
+ return cls
17
+
18
+ return decorator
19
+
20
+
21
+ def fastql_mutation_field(
22
+ *,
23
+ error_types: Sequence[type[GraphQLError]] = (),
24
+ auth_check: type[GraphQLPermission] | None = None,
25
+ iam_check: GraphQLPermission | None = None,
26
+ skip_permission_checks: bool = False,
27
+ ):
28
+ """
29
+ Custom field decorator that marks mutation fields for inclusion in FastQL.
30
+
31
+ Args
32
+ error_types: Possible error types raised by the query
33
+ auth_check: GraphQLPermission authentication class guarding this field
34
+ iam_check: GraphQLPermission authorization guarding this field
35
+ skip_permission_checks: Flag indicating that no authentication nor
36
+ authorization permissions will be checked for
37
+ this field (i.e. it is an open API)
38
+ """
39
+
40
+ def decorator(resolver: Callable):
41
+ all_error_types: set[type[GraphQLError]] = set()
42
+ all_error_types.update(error_types)
43
+ all_permission_instances: set[GraphQLPermission] = set()
44
+ if not skip_permission_checks:
45
+ # Process auth_check
46
+ auth_check_cls = auth_check
47
+ if auth_check_cls is None:
48
+ raise ValueError("No auth_check permission provided")
49
+
50
+ all_error_types.add(auth_check_cls.graphql_client_error_class)
51
+ all_error_types.add(auth_check_cls.graphql_server_error_class)
52
+ all_permission_instances.add(auth_check_cls())
53
+ # Process iam_check
54
+ if iam_check is not None:
55
+ iam_check_cls = type(iam_check)
56
+ all_error_types.add(iam_check_cls.graphql_client_error_class)
57
+ all_error_types.add(iam_check_cls.graphql_server_error_class)
58
+ all_permission_instances.add(iam_check)
59
+
60
+ # Sort all error types for stable code generation
61
+ all_error_types_sorted: list[type[GraphQLError]] = sorted(
62
+ all_error_types, key=lambda t: t.__name__
63
+ )
64
+
65
+ return_type = get_type_hints(resolver).get("return")
66
+ resolver_name = getattr(resolver, "__name__", "<unknown>")
67
+
68
+ if return_type is None:
69
+ raise TypeError(f"Missing return type hint for resolver: {resolver_name}")
70
+
71
+ if not valid_fastql_type_output(return_type):
72
+ raise TypeError(
73
+ f"Return type of {resolver_name} must be a valid @fastql_type_output type."
74
+ )
75
+
76
+ resolver._fastql_mutation_field = True # type: ignore[attr-defined]
77
+ resolver._fastql_return_type = return_type # type: ignore[attr-defined]
78
+ resolver._fastql_error_types = tuple(all_error_types_sorted) # type: ignore[attr-defined]
79
+ resolver._fastql_permission_instances = tuple(all_permission_instances) # type: ignore[attr-defined]
80
+ resolver._skip_permission_checks = skip_permission_checks # type: ignore[attr-defined]
81
+
82
+ return resolver
83
+
84
+ return decorator
@@ -0,0 +1,30 @@
1
+ from types import UnionType
2
+ from typing import Any, Union, get_args, get_origin
3
+
4
+ import strawberry
5
+
6
+
7
+ def extract_concrete_type(typ: Any) -> Any:
8
+ """
9
+ Given something like Optional[MyType] or List[MyType], return MyType.
10
+ """
11
+ origin = get_origin(typ)
12
+ if origin in {Union, UnionType, list, tuple}:
13
+ args = get_args(typ)
14
+ for arg in args:
15
+ if arg is not type(None): # skip NoneType
16
+ return extract_concrete_type(arg)
17
+ return typ
18
+
19
+
20
+ def fastql_type_output(cls: type[Any]):
21
+ """
22
+ Custom field decorator that marks output types (i.e. response types from APIs)
23
+ """
24
+ cls._fastql_type_output = True # type: ignore[attr-defined]
25
+ return strawberry.type(cls)
26
+
27
+
28
+ def valid_fastql_type_output(cls: Any) -> bool:
29
+ cls = extract_concrete_type(cls)
30
+ return hasattr(cls, "_fastql_type_output")
@@ -0,0 +1,86 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable, Sequence
4
+ from typing import get_type_hints
5
+
6
+ from apppy.fastql.annotation.output import valid_fastql_type_output
7
+ from apppy.fastql.errors import GraphQLError
8
+ from apppy.fastql.permissions import GraphQLPermission
9
+
10
+
11
+ def fastql_query():
12
+ """
13
+ Custom class decorator that marks query classes for inclusion in FastQL
14
+ """
15
+
16
+ def decorator(cls):
17
+ cls._fastql_is_query = True # type: ignore[attr-defined]
18
+ return cls
19
+
20
+ return decorator
21
+
22
+
23
+ def fastql_query_field(
24
+ *,
25
+ error_types: Sequence[type[GraphQLError]] = (),
26
+ auth_check: type[GraphQLPermission] | None = None,
27
+ iam_check: GraphQLPermission | None = None,
28
+ skip_permission_checks: bool = False,
29
+ ):
30
+ """
31
+ Custom field decorator that marks query fields for inclusion in FastQL.
32
+
33
+ Args
34
+ error_types: Possible error types raised by the query
35
+ auth_check: GraphQLPermission authentication class guarding this field
36
+ iam_check: GraphQLPermission authorization guarding this field
37
+ skip_permission_checks: Flag indicating that no authentication nor
38
+ authorization permissions will be checked for
39
+ this field (i.e. it is an open API)
40
+ """
41
+
42
+ def decorator(resolver: Callable):
43
+ all_error_types: set[type[GraphQLError]] = set()
44
+ all_error_types.update(error_types)
45
+ all_permission_instances: set[GraphQLPermission] = set()
46
+ if not skip_permission_checks:
47
+ # Process auth_check
48
+ auth_check_cls = auth_check
49
+ if auth_check_cls is None:
50
+ raise ValueError("No auth_check permission provided")
51
+
52
+ all_error_types.add(auth_check_cls.graphql_client_error_class)
53
+ all_error_types.add(auth_check_cls.graphql_server_error_class)
54
+ all_permission_instances.add(auth_check_cls())
55
+ # Process iam_check
56
+ if iam_check is not None:
57
+ iam_check_cls = type(iam_check)
58
+ all_error_types.add(iam_check_cls.graphql_client_error_class)
59
+ all_error_types.add(iam_check_cls.graphql_server_error_class)
60
+ all_permission_instances.add(iam_check)
61
+
62
+ # Sort all error types for stable code generation
63
+ all_error_types_sorted: list[type[GraphQLError]] = sorted(
64
+ all_error_types, key=lambda t: t.__name__
65
+ )
66
+
67
+ return_type = get_type_hints(resolver).get("return")
68
+ resolver_name = getattr(resolver, "__name__", "<unknown>")
69
+
70
+ if return_type is None:
71
+ raise TypeError(f"Missing return type hint for resolver: {resolver_name}")
72
+
73
+ if not valid_fastql_type_output(return_type):
74
+ raise TypeError(
75
+ f"Return type of {resolver_name} must be a valid @fastql_type_output type." # noqa: E501
76
+ )
77
+
78
+ resolver._fastql_query_field = True # type: ignore[attr-defined]
79
+ resolver._fastql_return_type = return_type # type: ignore[attr-defined]
80
+ resolver._fastql_error_types = tuple(all_error_types_sorted) # type: ignore[attr-defined]
81
+ resolver._fastql_permission_instances = tuple(all_permission_instances) # type: ignore[attr-defined]
82
+ resolver._skip_permission_checks = skip_permission_checks # type: ignore[attr-defined]
83
+
84
+ return resolver
85
+
86
+ return decorator
@@ -0,0 +1,43 @@
1
+ from apppy.fastql.annotation.error import fastql_type_error
2
+ from apppy.fastql.annotation.interface import fastql_type_interface
3
+
4
+
5
+ # NOTE: Do not use GraphQLError directly
6
+ # instead use GraphQLClientError or GraphQLServerError
7
+ @fastql_type_interface
8
+ class GraphQLError(BaseException):
9
+ """Generic base class for any error raised in a GraphQL API"""
10
+
11
+ code: str
12
+
13
+ def __init__(self, code: str):
14
+ self.code: str = code
15
+
16
+
17
+ @fastql_type_interface
18
+ class GraphQLClientError(GraphQLError):
19
+ """Base class for any GraphQL error raised related to bad client input"""
20
+
21
+ def __init__(self, code: str = "generic_client_error"):
22
+ super().__init__(code)
23
+
24
+
25
+ @fastql_type_interface
26
+ class GraphQLServerError(GraphQLError):
27
+ """Base class for any GraphQL error raised related to internal server processing"""
28
+
29
+ def __init__(self, code: str = "generic_server_error"):
30
+ super().__init__(code)
31
+
32
+
33
+ @fastql_type_error
34
+ class TypedIdInvalidPrefixError(GraphQLClientError):
35
+ """Raised when a TypedId encounters an invalid prefix"""
36
+
37
+ id: str
38
+ id_type: str
39
+
40
+ def __init__(self, id: str, id_type: str):
41
+ super().__init__("typed_id_invalid_prefix")
42
+ self.id = id
43
+ self.id_type = id_type
@@ -0,0 +1,27 @@
1
+ import abc
2
+ from typing import Any
3
+
4
+ from strawberry.permission import BasePermission
5
+
6
+ from apppy.fastql.errors import GraphQLClientError, GraphQLServerError
7
+
8
+
9
+ class GraphQLPermission(BasePermission):
10
+ """
11
+ A generic base class which represents a graphql permission.
12
+ """
13
+
14
+ # In GraphQL, there are strongly typed errors returned.
15
+ # Here, we allow an permission class to declare the GraphQL
16
+ # errors that are to be returned on either a server-side error
17
+ # or a client-side error
18
+ graphql_client_error_class: type[GraphQLClientError]
19
+ graphql_server_error_class: type[GraphQLServerError]
20
+
21
+ def on_unauthorized(self) -> None:
22
+ error = self.graphql_client_error_class(self.graphql_client_error_args()) # type: ignore[arg-type]
23
+ raise error
24
+
25
+ @abc.abstractmethod
26
+ def graphql_client_error_args(self) -> tuple[Any, ...]:
27
+ pass
@@ -0,0 +1,101 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import Optional
3
+
4
+ from pydantic import Field
5
+ from sqids import Sqids
6
+
7
+ from apppy.env import EnvSettings
8
+ from apppy.fastql.annotation.interface import fastql_type_interface
9
+ from apppy.fastql.errors import TypedIdInvalidPrefixError
10
+
11
+
12
+ class TypedIdEncoderSettings(EnvSettings):
13
+ alphabet: str = Field(alias="APP_GENERIC_TYPED_ID_ENCODER_ALPHABET", exclude=True)
14
+ min_length: int = Field(alias="APP_GENERIC_TYPED_ID_ENCODER_MIN_LENGTH", default=10)
15
+
16
+
17
+ class TypedIdEncoder:
18
+ """
19
+ Service to encode ids into strings based on a static
20
+ alphabet. This allows the system to ofuscate the integer
21
+ values to outside parties (e.g. database primary keys)
22
+ """
23
+
24
+ # NOTE: We use a global instance (i.e. static singleton) for
25
+ # TypedIdEncoder because we would like it to be used by all TypedId
26
+ # instances which will not be instantiated via the app container. So
27
+ # instead we'll have this static reference that they are able to use.
28
+ _global_instance: Optional["TypedIdEncoder"] = None
29
+
30
+ def __init__(self, settings: TypedIdEncoderSettings) -> None:
31
+ self._settings = settings
32
+ self._encoder = Sqids(alphabet=settings.alphabet, min_length=settings.min_length)
33
+
34
+ ##### ##### ##### Integers ##### ##### #####
35
+ # Used to encode and decode integers. Which is
36
+ # useful for items like database primary keys
37
+
38
+ def encode_int(self, value: int) -> str:
39
+ return self._encoder.encode([value])
40
+
41
+ def decode_int(self, value: str) -> int:
42
+ return self._encoder.decode(value)[0]
43
+
44
+ @classmethod
45
+ def get_global(cls) -> "TypedIdEncoder":
46
+ if cls._global_instance is None:
47
+ raise RuntimeError("TypedIdEncoder has not been initialized.")
48
+ return cls._global_instance
49
+
50
+ @classmethod
51
+ def set_global(cls, instance: "TypedIdEncoder") -> None:
52
+ if cls._global_instance is None:
53
+ cls._global_instance = instance
54
+
55
+
56
+ @fastql_type_interface
57
+ class TypedId(ABC):
58
+ """
59
+ Base class for all typed ids. A typed id has a prefix
60
+ which signals it's type and an encoded value which
61
+ can be shared externally (i.e. without security concerns).
62
+ """
63
+
64
+ def __init__(self, encoded_int: str) -> None:
65
+ super().__init__()
66
+ self._encoded_int = encoded_int
67
+
68
+ @property
69
+ @abstractmethod
70
+ def prefix(self) -> str:
71
+ pass
72
+
73
+ @property
74
+ def number(self) -> int:
75
+ return TypedIdEncoder.get_global().decode_int(self._encoded_int)
76
+
77
+ def __str__(self) -> str:
78
+ return f"{self.prefix}_{self._encoded_int}"
79
+
80
+ @classmethod
81
+ def from_number(cls, id: int):
82
+ return cls(TypedIdEncoder.get_global().encode_int(id))
83
+
84
+ @classmethod
85
+ def from_str(cls, id: str):
86
+ prefix = cls._get_prefix()
87
+ if not id.startswith(f"{prefix}_"):
88
+ raise TypedIdInvalidPrefixError(id=id, id_type=cls.__name__)
89
+
90
+ encoded_int = id[len(f"{prefix}_") :]
91
+ return cls(encoded_int)
92
+
93
+ @classmethod
94
+ def is_valid(cls, id: str):
95
+ prefix = cls._get_prefix()
96
+ return id.startswith(f"{prefix}_")
97
+
98
+ @classmethod
99
+ def _get_prefix(cls):
100
+ instance = cls.__new__(cls)
101
+ return instance.prefix
@@ -0,0 +1,70 @@
1
+ import pytest
2
+
3
+ from apppy.env import DictEnv, Env
4
+ from apppy.fastql.errors import TypedIdInvalidPrefixError
5
+ from apppy.fastql.typed_id import TypedId, TypedIdEncoder, TypedIdEncoderSettings
6
+
7
+ _typed_id_encoder_env_test: Env = DictEnv(
8
+ name="int_encoder_test",
9
+ d={
10
+ "APP_GENERIC_TYPED_ID_ENCODER_ALPHABET": "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890" # noqa: E501
11
+ },
12
+ )
13
+ _typed_id_encoder_settings_test: TypedIdEncoderSettings = TypedIdEncoderSettings( # type: ignore[misc]
14
+ _typed_id_encoder_env_test # type: ignore[arg-type]
15
+ )
16
+ _typed_id_encoder_test: TypedIdEncoder = TypedIdEncoder(_typed_id_encoder_settings_test)
17
+ TypedIdEncoder.set_global(_typed_id_encoder_test)
18
+
19
+
20
+ def test_int_encoder():
21
+ encoded_int = _typed_id_encoder_test.encode_int(5)
22
+ assert encoded_int == "tGj3JHachG"
23
+
24
+ decoded_int = _typed_id_encoder_test.decode_int("tGj3JHachG")
25
+ assert decoded_int == 5
26
+
27
+ encoded_int = _typed_id_encoder_test.encode_int(9_999_999_999)
28
+ assert encoded_int == "Tnega83VLI"
29
+
30
+ decoded_int = _typed_id_encoder_test.decode_int("Tnega83VLI")
31
+ assert decoded_int == 9_999_999_999
32
+
33
+
34
+ class TypedTestId(TypedId):
35
+ @property
36
+ def prefix(self) -> str:
37
+ return "test"
38
+
39
+
40
+ def test_id_from_number():
41
+ id1 = TypedTestId.from_number(1)
42
+ assert str(id1) == "test_FrwMELkAmX"
43
+ assert id1.number == 1
44
+
45
+ id2 = TypedTestId.from_number(2)
46
+ assert str(id2) == "test_nLKnICbr1y"
47
+ assert id2.number == 2
48
+
49
+
50
+ def test_id_from_str():
51
+ id1 = TypedTestId.from_str("test_FrwMELkAmX")
52
+ assert str(id1) == "test_FrwMELkAmX"
53
+ assert id1.number == 1
54
+
55
+ id2 = TypedTestId.from_str("test_nLKnICbr1y")
56
+ assert str(id2) == "test_nLKnICbr1y"
57
+ assert id2.number == 2
58
+
59
+
60
+ def test_id_is_valid():
61
+ valid = TypedTestId.is_valid("test_FrwMELkAmX")
62
+ assert valid is True
63
+
64
+ invalid = TypedTestId.is_valid("invalid_FrwMELkAmX")
65
+ assert invalid is False
66
+
67
+
68
+ def test_id_invalid_prefix():
69
+ with pytest.raises(TypedIdInvalidPrefixError):
70
+ TypedTestId.from_str("invalid_FrwMELkAmX")