TypeDAL 4.4.4__tar.gz → 4.4.6__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 (74) hide show
  1. {typedal-4.4.4 → typedal-4.4.6}/CHANGELOG.md +17 -0
  2. {typedal-4.4.4 → typedal-4.4.6}/PKG-INFO +44 -11
  3. {typedal-4.4.4 → typedal-4.4.6}/README.md +43 -10
  4. typedal-4.4.6/docs/10_advanced_apis.md +76 -0
  5. {typedal-4.4.4 → typedal-4.4.6}/docs/2_defining_tables.md +2 -2
  6. {typedal-4.4.4 → typedal-4.4.6}/docs/3_building_queries.md +8 -3
  7. {typedal-4.4.4 → typedal-4.4.6}/docs/4_relationships.md +24 -1
  8. {typedal-4.4.4 → typedal-4.4.6}/docs/7_configuration.md +1 -1
  9. {typedal-4.4.4 → typedal-4.4.6}/docs/9_memoization.md +5 -0
  10. {typedal-4.4.4 → typedal-4.4.6}/docs/index.md +1 -0
  11. {typedal-4.4.4 → typedal-4.4.6}/mkdocs.yml +2 -1
  12. {typedal-4.4.4 → typedal-4.4.6}/src/typedal/__about__.py +1 -1
  13. {typedal-4.4.4 → typedal-4.4.6}/src/typedal/caching.py +1 -1
  14. {typedal-4.4.4 → typedal-4.4.6}/src/typedal/config.py +1 -3
  15. {typedal-4.4.4 → typedal-4.4.6}/src/typedal/core.py +7 -7
  16. {typedal-4.4.4 → typedal-4.4.6}/src/typedal/define.py +3 -3
  17. {typedal-4.4.4 → typedal-4.4.6}/src/typedal/mixins.py +1 -1
  18. {typedal-4.4.4 → typedal-4.4.6}/src/typedal/query_builder.py +9 -4
  19. {typedal-4.4.4 → typedal-4.4.6}/src/typedal/relationships.py +28 -5
  20. {typedal-4.4.4 → typedal-4.4.6}/tests/test_query_builder.py +8 -0
  21. {typedal-4.4.4 → typedal-4.4.6}/tests/test_relationships.py +4 -3
  22. typedal-4.4.4/.crush/.gitignore +0 -1
  23. typedal-4.4.4/.crush/crush.db-shm +0 -0
  24. typedal-4.4.4/.crush/crush.db-wal +0 -0
  25. typedal-4.4.4/.crush/init +0 -0
  26. typedal-4.4.4/.crush/logs/crush.log +0 -34
  27. {typedal-4.4.4 → typedal-4.4.6}/.github/workflows/su6.yml +0 -0
  28. {typedal-4.4.4 → typedal-4.4.6}/.gitignore +0 -0
  29. {typedal-4.4.4 → typedal-4.4.6}/.readthedocs.yml +0 -0
  30. {typedal-4.4.4 → typedal-4.4.6}/coverage.svg +0 -0
  31. {typedal-4.4.4 → typedal-4.4.6}/docs/1_getting_started.md +0 -0
  32. {typedal-4.4.4 → typedal-4.4.6}/docs/5_py4web.md +0 -0
  33. {typedal-4.4.4 → typedal-4.4.6}/docs/6_migrations.md +0 -0
  34. {typedal-4.4.4 → typedal-4.4.6}/docs/8_mixins.md +0 -0
  35. {typedal-4.4.4 → typedal-4.4.6}/docs/css/code_blocks.css +0 -0
  36. {typedal-4.4.4 → typedal-4.4.6}/docs/requirements.txt +0 -0
  37. {typedal-4.4.4 → typedal-4.4.6}/example_new.py +0 -0
  38. {typedal-4.4.4 → typedal-4.4.6}/example_old.py +0 -0
  39. {typedal-4.4.4 → typedal-4.4.6}/pyproject.toml +0 -0
  40. {typedal-4.4.4 → typedal-4.4.6}/src/typedal/__init__.py +0 -0
  41. {typedal-4.4.4 → typedal-4.4.6}/src/typedal/cli.py +0 -0
  42. {typedal-4.4.4 → typedal-4.4.6}/src/typedal/constants.py +0 -0
  43. {typedal-4.4.4 → typedal-4.4.6}/src/typedal/fields.py +0 -0
  44. {typedal-4.4.4 → typedal-4.4.6}/src/typedal/for_py4web.py +0 -0
  45. {typedal-4.4.4 → typedal-4.4.6}/src/typedal/for_web2py.py +0 -0
  46. {typedal-4.4.4 → typedal-4.4.6}/src/typedal/helpers.py +0 -0
  47. {typedal-4.4.4 → typedal-4.4.6}/src/typedal/py.typed +0 -0
  48. {typedal-4.4.4 → typedal-4.4.6}/src/typedal/rows.py +0 -0
  49. {typedal-4.4.4 → typedal-4.4.6}/src/typedal/serializers/as_json.py +0 -0
  50. {typedal-4.4.4 → typedal-4.4.6}/src/typedal/tables.py +0 -0
  51. {typedal-4.4.4 → typedal-4.4.6}/src/typedal/types.py +0 -0
  52. {typedal-4.4.4 → typedal-4.4.6}/src/typedal/web2py_py4web_shared.py +0 -0
  53. {typedal-4.4.4 → typedal-4.4.6}/tasks.py +0 -0
  54. {typedal-4.4.4 → typedal-4.4.6}/tests/__init__.py +0 -0
  55. {typedal-4.4.4 → typedal-4.4.6}/tests/configs/simple.toml +0 -0
  56. {typedal-4.4.4 → typedal-4.4.6}/tests/configs/valid.env +0 -0
  57. {typedal-4.4.4 → typedal-4.4.6}/tests/configs/valid.toml +0 -0
  58. {typedal-4.4.4 → typedal-4.4.6}/tests/py314_tests.py +0 -0
  59. {typedal-4.4.4 → typedal-4.4.6}/tests/test_cli.py +0 -0
  60. {typedal-4.4.4 → typedal-4.4.6}/tests/test_config.py +0 -0
  61. {typedal-4.4.4 → typedal-4.4.6}/tests/test_docs_examples.py +0 -0
  62. {typedal-4.4.4 → typedal-4.4.6}/tests/test_helpers.py +0 -0
  63. {typedal-4.4.4 → typedal-4.4.6}/tests/test_json.py +0 -0
  64. {typedal-4.4.4 → typedal-4.4.6}/tests/test_main.py +0 -0
  65. {typedal-4.4.4 → typedal-4.4.6}/tests/test_mixins.py +0 -0
  66. {typedal-4.4.4 → typedal-4.4.6}/tests/test_mypy.py +0 -0
  67. {typedal-4.4.4 → typedal-4.4.6}/tests/test_orm.py +0 -0
  68. {typedal-4.4.4 → typedal-4.4.6}/tests/test_py4web.py +0 -0
  69. {typedal-4.4.4 → typedal-4.4.6}/tests/test_row.py +0 -0
  70. {typedal-4.4.4 → typedal-4.4.6}/tests/test_stats.py +0 -0
  71. {typedal-4.4.4 → typedal-4.4.6}/tests/test_table.py +0 -0
  72. {typedal-4.4.4 → typedal-4.4.6}/tests/test_web2py.py +0 -0
  73. {typedal-4.4.4 → typedal-4.4.6}/tests/test_xx_others.py +0 -0
  74. {typedal-4.4.4 → typedal-4.4.6}/tests/timings.py +0 -0
@@ -2,6 +2,23 @@
2
2
 
3
3
  <!--next-version-placeholder-->
4
4
 
5
+ ## v4.4.6 (2026-03-05)
6
+
7
+ ### Fix
8
+
9
+ * Support .first() when using querybuilder on pydal-style tables. ([`d75f254`](https://github.com/trialandsuccess/TypeDAL/commit/d75f25440f0dcca51991bc047bf7ce8d479f942b))
10
+
11
+ ### Documentation
12
+
13
+ * Make docs up-to-date with recently introduced features, improve reading flow for new users ([`64bb7be`](https://github.com/trialandsuccess/TypeDAL/commit/64bb7be220c5f1a5998940b320565b5a3dbca988))
14
+
15
+ ## v4.4.5 (2026-02-27)
16
+
17
+ ### Fix
18
+
19
+ * Add `Ref` type so you can do forward references in types, ([`3240a74`](https://github.com/trialandsuccess/TypeDAL/commit/3240a747a4dd63a887464c0a748c68907d38d7d0))
20
+ * Pass known classes as namespace so `col: "ForwardRef"` works in 3.13 too ([`b655da8`](https://github.com/trialandsuccess/TypeDAL/commit/b655da812d7ea014b95adf73974ca301a6c42ca2))
21
+
5
22
  ## v4.4.4 (2026-02-25)
6
23
 
7
24
  ### Fix
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: TypeDAL
3
- Version: 4.4.4
3
+ Version: 4.4.6
4
4
  Summary: Typing support for PyDAL
5
5
  Project-URL: Documentation, https://typedal.readthedocs.io/
6
6
  Project-URL: Issues, https://github.com/trialandsuccess/TypeDAL/issues
@@ -81,13 +81,49 @@ the underlying `db.define_table` pydal Tables.
81
81
  e.g. `rows: TypedRows[SomeTable] = db(...).select()`. When using the QueryBuilder, a `TypedRows` instance is returned
82
82
  by `.collect()`.
83
83
 
84
- Version 2.0 also introduces more ORM-like funcionality.
84
+ Version 2.0 also introduces more ORM-like functionality.
85
85
  Most notably, a Typed Query Builder that sees your table classes as models with relationships to each other.
86
86
  See [3. Building Queries](https://typedal.readthedocs.io/en/stable/3_building_queries/) for more
87
87
  details.
88
88
 
89
+ ## Quickstart
90
+
91
+ ```bash
92
+ uv pip install typedal
93
+ # alternative:
94
+ pip install typedal
95
+ ```
96
+
97
+ ```python
98
+ from typedal import TypeDAL, TypedTable
99
+
100
+ db = TypeDAL("sqlite:memory")
101
+ # Alternatives:
102
+ # db = TypeDAL("sqlite://storage.sqlite")
103
+ # db = TypeDAL("postgres://user:password@localhost:5432/mydb")
104
+ # db = TypeDAL("mysql://user:password@localhost:3306/mydb")
105
+ # ...
106
+
107
+ @db.define()
108
+ class User(TypedTable):
109
+ name: str
110
+ age: int | None
111
+
112
+
113
+ User.insert(name="Alice", age=30)
114
+ adults = User.where(User.age >= 18).collect()
115
+ print(adults.column("name")) # ['Alice']
116
+ ```
117
+
118
+ If you are new to TypeDAL, start with:
119
+
120
+ 1. [Getting Started](https://typedal.readthedocs.io/en/stable/1_getting_started/)
121
+ 2. [Defining Tables](https://typedal.readthedocs.io/en/stable/2_defining_tables/)
122
+ 3. [Building Queries](https://typedal.readthedocs.io/en/stable/3_building_queries/)
123
+ 4. [Relationships](https://typedal.readthedocs.io/en/stable/4_relationships/)
124
+
89
125
  ## CLI
90
- The Typedal CLI provides a convenient interface for generating SQL migrations for [edwh-migrate](https://github.com/educationwarehouse/migrate#readme)
126
+ The TypeDAL CLI provides a convenient interface for generating SQL migrations for [edwh-migrate](https://github.com/educationwarehouse/migrate#readme)
91
127
  from PyDAL or TypeDAL configurations using [pydal2sql](https://github.com/robinvandernoord/pydal2sql).
92
128
  It offers various commands to streamline database management tasks.
93
129
 
@@ -252,7 +288,7 @@ row = db.table_name(id=1) # -> Any (Row)
252
288
  # all:
253
289
  all_rows = TableName.collect() # or .all()
254
290
  # some:
255
- # order of select and where is interchangable here
291
+ # order of select and where is interchangeable here
256
292
  rows = TableName.select(Tablename.id).where(TableName.id > 5).where(TableName.id < 50).collect()
257
293
  # one:
258
294
  row = TableName(id=1) # or .where(...).first()
@@ -330,13 +366,10 @@ db.commit() # this is usually done automatically but sometimes you want to manua
330
366
 
331
367
  ## Caveats
332
368
 
333
- - This package depends heavily on the current implementation of annotations (which are computed when the class is
334
- defined). PEP 563 (Postponed Evaluation of Annotations, accepted) aims to change this behavior (
335
- and `from __future__ import annotations` already does) in a way that this module currently can not handle: all
336
- annotations are converted to string representations. This makes it very hard to re-evaluate the annotation into the
337
- original type, since the variable scope is lost (and thus references to variables or other classes are ambiguous or
338
- simply impossible to find).
369
+ - Some editors (notably PyCharm) cannot always distinguish class-level and instance-level access on the same symbol.
370
+ For example, `Model.somefield` is a field descriptor (query operations like `.belongs()`), while
371
+ `model.somefield` is the runtime value (for example `list[str]`).
339
372
  - `TypedField` limitations; Since pydal implements some magic methods to perform queries, some features of typing will
340
373
  not work on a typed field: `typing.Optional` or a union (`Field() | None`) will result in errors. The only way to make
341
374
  a typedfield optional right now, would be to set `required=False` as an argument yourself. This is also a reason
342
- why `typing.get_type_hints` is not a solution for the first caveat.
375
+ why `typing.get_type_hints` is not a complete solution.
@@ -24,13 +24,49 @@ the underlying `db.define_table` pydal Tables.
24
24
  e.g. `rows: TypedRows[SomeTable] = db(...).select()`. When using the QueryBuilder, a `TypedRows` instance is returned
25
25
  by `.collect()`.
26
26
 
27
- Version 2.0 also introduces more ORM-like funcionality.
27
+ Version 2.0 also introduces more ORM-like functionality.
28
28
  Most notably, a Typed Query Builder that sees your table classes as models with relationships to each other.
29
29
  See [3. Building Queries](https://typedal.readthedocs.io/en/stable/3_building_queries/) for more
30
30
  details.
31
31
 
32
+ ## Quickstart
33
+
34
+ ```bash
35
+ uv pip install typedal
36
+ # alternative:
37
+ pip install typedal
38
+ ```
39
+
40
+ ```python
41
+ from typedal import TypeDAL, TypedTable
42
+
43
+ db = TypeDAL("sqlite:memory")
44
+ # Alternatives:
45
+ # db = TypeDAL("sqlite://storage.sqlite")
46
+ # db = TypeDAL("postgres://user:password@localhost:5432/mydb")
47
+ # db = TypeDAL("mysql://user:password@localhost:3306/mydb")
48
+ # ...
49
+
50
+ @db.define()
51
+ class User(TypedTable):
52
+ name: str
53
+ age: int | None
54
+
55
+
56
+ User.insert(name="Alice", age=30)
57
+ adults = User.where(User.age >= 18).collect()
58
+ print(adults.column("name")) # ['Alice']
59
+ ```
60
+
61
+ If you are new to TypeDAL, start with:
62
+
63
+ 1. [Getting Started](https://typedal.readthedocs.io/en/stable/1_getting_started/)
64
+ 2. [Defining Tables](https://typedal.readthedocs.io/en/stable/2_defining_tables/)
65
+ 3. [Building Queries](https://typedal.readthedocs.io/en/stable/3_building_queries/)
66
+ 4. [Relationships](https://typedal.readthedocs.io/en/stable/4_relationships/)
67
+
32
68
  ## CLI
33
- The Typedal CLI provides a convenient interface for generating SQL migrations for [edwh-migrate](https://github.com/educationwarehouse/migrate#readme)
69
+ The TypeDAL CLI provides a convenient interface for generating SQL migrations for [edwh-migrate](https://github.com/educationwarehouse/migrate#readme)
34
70
  from PyDAL or TypeDAL configurations using [pydal2sql](https://github.com/robinvandernoord/pydal2sql).
35
71
  It offers various commands to streamline database management tasks.
36
72
 
@@ -195,7 +231,7 @@ row = db.table_name(id=1) # -> Any (Row)
195
231
  # all:
196
232
  all_rows = TableName.collect() # or .all()
197
233
  # some:
198
- # order of select and where is interchangable here
234
+ # order of select and where is interchangeable here
199
235
  rows = TableName.select(Tablename.id).where(TableName.id > 5).where(TableName.id < 50).collect()
200
236
  # one:
201
237
  row = TableName(id=1) # or .where(...).first()
@@ -273,13 +309,10 @@ db.commit() # this is usually done automatically but sometimes you want to manua
273
309
 
274
310
  ## Caveats
275
311
 
276
- - This package depends heavily on the current implementation of annotations (which are computed when the class is
277
- defined). PEP 563 (Postponed Evaluation of Annotations, accepted) aims to change this behavior (
278
- and `from __future__ import annotations` already does) in a way that this module currently can not handle: all
279
- annotations are converted to string representations. This makes it very hard to re-evaluate the annotation into the
280
- original type, since the variable scope is lost (and thus references to variables or other classes are ambiguous or
281
- simply impossible to find).
312
+ - Some editors (notably PyCharm) cannot always distinguish class-level and instance-level access on the same symbol.
313
+ For example, `Model.somefield` is a field descriptor (query operations like `.belongs()`), while
314
+ `model.somefield` is the runtime value (for example `list[str]`).
282
315
  - `TypedField` limitations; Since pydal implements some magic methods to perform queries, some features of typing will
283
316
  not work on a typed field: `typing.Optional` or a union (`Field() | None`) will result in errors. The only way to make
284
317
  a typedfield optional right now, would be to set `required=False` as an argument yourself. This is also a reason
285
- why `typing.get_type_hints` is not a solution for the first caveat.
318
+ why `typing.get_type_hints` is not a complete solution.
@@ -0,0 +1,76 @@
1
+ # 10. Advanced APIs
2
+
3
+ This chapter documents a few public APIs that are useful in specific cases, but are not part of the default onboarding flow.
4
+
5
+ ## QueryBuilder on old-style pyDAL tables
6
+
7
+ If you are migrating incrementally, you can still use TypeDAL's query builder on existing pyDAL tables:
8
+
9
+ ```python
10
+ from typedal import QueryBuilder
11
+
12
+ rows = QueryBuilder(db.some_table).where(id=2).collect()
13
+ ```
14
+
15
+ This gives you partial builder ergonomics while keeping your existing table definitions.
16
+
17
+ > **Important:** support is intentionally limited for old-style tables.
18
+ > Internally, `.collect()` is effectively a passthrough to `.execute()` and returns regular pyDAL `Rows`.
19
+ > `.first()`/`.first_or_fail()` return a regular pyDAL `Row` in this mode.
20
+
21
+ ### Verified working methods
22
+
23
+ - Query composition: `.where(...)`, `.select(...)`, `.orderby(...)`, `.groupby(...)`, `.having(...)`
24
+ - Execution/introspection: `.execute()`, `.collect()`, `.to_sql()`
25
+ - Row access: `.first()`, `.first_or_fail()`
26
+ - Pagination helpers: `.paginate()`, `.chunk()`
27
+ - Basic set operations: `.count()`, `.exists()`, `.update(...)`, `.delete(...)`, `.collect_or_fail()`
28
+ - `.cache(...).collect()` runs (returns pyDAL `Rows`)
29
+
30
+ ### Verified unsupported methods
31
+
32
+ - `.join(...)`: **not supported** for old-style tables (depends on TypeDAL model relationship internals)
33
+
34
+ ### Behavioral caveat
35
+
36
+ Legacy mode does not perform typed model mapping. Expect pyDAL `Rows`/`Row` outputs rather than typed entities.
37
+
38
+ If you need full QueryBuilder behavior (typed entities, relationships, typed joins, cache integration),
39
+ migrate that table to `TypedTable`.
40
+
41
+ ## Upsert and validation helpers
42
+
43
+ `TypedTable` exposes convenience methods for common upsert/validation flows:
44
+
45
+ ```python
46
+ # update if found, otherwise insert
47
+ user = User.update_or_insert(User.email == "a@example.com", email="a@example.com", name="Alice")
48
+
49
+ # validate before insert
50
+ created, errors = User.validate_and_insert(email="a@example.com")
51
+
52
+ # validate before update
53
+ updated, errors = User.validate_and_update(User.id == 1, name="Alice Updated")
54
+
55
+ # validate before update-or-insert
56
+ row, errors = User.validate_and_update_or_insert(User.email == "a@example.com", name="Alice")
57
+ ```
58
+
59
+ Behavior notes:
60
+
61
+ - `update_or_insert(...)` returns the resulting instance.
62
+ - `validate_and_*` methods return `(instance_or_none, errors_or_none)`.
63
+
64
+ ## Reordering table fields
65
+
66
+ You can reorder fields on a defined table with `reorder_fields`:
67
+
68
+ ```python
69
+ # Keep listed fields first, keep all other fields after them
70
+ MyTable.reorder_fields(MyTable.id, MyTable.name)
71
+
72
+ # Keep only the listed fields
73
+ MyTable.reorder_fields(MyTable.id, MyTable.name, keep_others=False)
74
+ ```
75
+
76
+ This is useful when you want deterministic field order for SQL generation, inspection, or exports.
@@ -40,7 +40,7 @@ Any keyword arguments you would pass to `db.define_table`, you can also pass to
40
40
  | `Field('name', 'time')` | `name: datetime.time` | `name: TypedField[datetime.time]` | `name = TypedField(datetime.time)` | `name = TimeField()` |
41
41
  | `Field('name', 'datetime')` | `name: datetime.datetime` | `name: TypedField[datetime.datetime]` | `name = TypedField(datetime.datetime)` | `name = DatetimeField()` |
42
42
  | `Field('name', 'password')` | × | × | `name = TypedField(str, type="password")` | `name = PasswordField()` |
43
- | `Field('name', 'upload')` | × | × | `name = TypedField(str, type="upload)` | `name = UploadField()` |
43
+ | `Field('name', 'upload')` | × | × | `name = TypedField(str, type="upload")` | `name = UploadField()` |
44
44
  | `Field('name', 'reference <table>')` | `name: Table` | `name: TypedField[Table]` | `name = TypedField(Table)` | `name = ReferenceField('table')` |
45
45
  | `Field('name', 'list:string')` | `name: list[str]` | `name: TypedField[list[str]]` | `name = TypedField(list[str])` | `name = ListStringField()` |
46
46
  | `Field('name', 'list:integer')` | `name: list[int]` | `name: TypedField[list[int]]` | `name = TypedField()` | `name = ListIntegerField()` |
@@ -104,4 +104,4 @@ row.delete_record() # to trigger
104
104
  MyTable.where(...).delete() # to trigger
105
105
  ```
106
106
 
107
- "Now that we have some tables, it's time to actually query them! Let's go to [3. Building Queries](./3_building_queries.md) to learn how.
107
+ Now that we have some tables, it's time to actually query them! Let's go to [3. Building Queries](./3_building_queries.md) to learn how.
@@ -236,8 +236,8 @@ The Query Builder has a few operations that don't return a new builder instance:
236
236
  - first: get the first entity matching your query, possibly with relationships loaded (if .join was used)
237
237
  - first_or_fail: where `first` may return an empty result, this variant will raise an error if there are no results.
238
238
  - to_sql: get the SQL string that would run, useful for debugging, subqueries and other advanced SQL operations.
239
- - update: instead of selecting rows, update those matching the current query (see [Delete](#delete))
240
- - delete: instead of selecting rows, delete those matching the current query (see [Update](#update))
239
+ - update: instead of selecting rows, update those matching the current query (see [Update](#update))
240
+ - delete: instead of selecting rows, delete those matching the current query (see [Delete](#delete))
241
241
 
242
242
  Additionally, you can directly call `.all()`, `.collect()`, `.count()`, `.first()` on a model (e.g. `User.all()`).
243
243
 
@@ -264,7 +264,7 @@ person.update_record(name="New Name")
264
264
  db(db.person.name == "Old Name").delete()
265
265
 
266
266
  row = db.person(4)
267
- row.update_record(name="New Name")
267
+ row.delete_record()
268
268
 
269
269
  # typedal:
270
270
  Person.where(id="Old Name").delete() # via query builder
@@ -272,3 +272,8 @@ Person.where(id="Old Name").delete() # via query builder
272
272
  person = Person(4)
273
273
  person.delete_record()
274
274
  ```
275
+
276
+ ---
277
+
278
+ Need less-common query patterns (for example, using `QueryBuilder` on old-style pyDAL tables)?
279
+ See [10. Advanced APIs](./10_advanced_apis.md).
@@ -98,6 +98,30 @@ In this example, `Relationship["Sidekick"]` is added as an extra type hint, sinc
98
98
  after the superhero class.
99
99
  Adding the `Relationship["Sidekick"]` hint is optional, but recommended to improve editor support.
100
100
 
101
+ ### Forward References with `Ref[...]` (typing helper)
102
+
103
+ For relationship targets, you can use `Ref[...]` as a typed replacement for the string form:
104
+
105
+ ```python
106
+ from typedal.relationships import Ref
107
+
108
+ bestie = relationship(Ref["BestFriend"], lambda user, bestie: user.id == bestie.friend)
109
+ ```
110
+
111
+ This is equivalent at runtime to:
112
+
113
+ ```python
114
+ bestie = relationship("BestFriend", lambda user, bestie: user.id == bestie.friend)
115
+ ```
116
+
117
+ `Ref[...]` is primarily for type-checking/editor support. It does not change runtime behavior.
118
+
119
+ For normal table fields, `Ref[...]` is not needed. Use standard forward-reference annotations, for example:
120
+
121
+ ```python
122
+ owner: "User"
123
+ ```
124
+
101
125
  ## Many-to-Many
102
126
 
103
127
  Setting up a relationship that uses a junction/pivot table is slightly harder.
@@ -199,4 +223,3 @@ Depending on your setup:
199
223
  - **Using py4web or web2py?** → [5. py4web & web2py](./5_py4web.md)
200
224
  - **Ready to manage your database?** → [6. Migrations](./6_migrations.md)
201
225
  - **Dive deeper into functionality?** → [8.: Mixins](./8_mixins.md)
202
-
@@ -55,7 +55,7 @@ noop = false
55
55
  ### Generating Migrations (pydal2sql)
56
56
 
57
57
  - **`input`**: Path to your TypeDAL table definitions file
58
- - **`output`**: Path to the generated migration `.py` fil
58
+ - **`output`**: Path to the generated migration `.py` file
59
59
  - **`dialect`**: Database type: `sqlite`, `postgres`, `mysql`, etc. (if unclear from database uri)
60
60
  - **`magic`**: Insert missing variables to prevent crashes (default: `true`).
61
61
  See [pydal2sql docs](https://github.com/robinvandernoord/pydal2sql#configuration).
@@ -148,3 +148,8 @@ class SpecialTable(TypedTable):
148
148
  ```
149
149
 
150
150
  **Warning:** Disabling this may break caching functionality for queries involving this table.
151
+
152
+ ---
153
+
154
+ Want to explore less common but useful APIs (like old-style pyDAL `QueryBuilder`, validation/upsert helpers, and field reordering)?
155
+ Continue with [10. Advanced APIs](./10_advanced_apis.md).
@@ -9,3 +9,4 @@
9
9
  7. [Advanced Configuration](./7_configuration.md)
10
10
  8. [Mixins](./8_mixins.md)
11
11
  9. [Function Memoization](./9_memoization.md)
12
+ 10. [Advanced APIs](./10_advanced_apis.md)
@@ -11,6 +11,7 @@ nav:
11
11
  - 7. Configuration: 7_configuration.md
12
12
  - 8. Mixins: 8_mixins.md
13
13
  - 9. Function Memoization: 9_memoization.md
14
+ - 10. Advanced APIs: 10_advanced_apis.md
14
15
  extra:
15
16
  version:
16
17
  default: stable
@@ -40,4 +41,4 @@ theme:
40
41
 
41
42
  extra_css:
42
43
  - https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/styles/tokyo-night-dark.min.css
43
- - css/code_blocks.css
44
+ - css/code_blocks.css
@@ -5,4 +5,4 @@ This file contains the Version info for this package.
5
5
  # SPDX-FileCopyrightText: 2023-present Robin van der Noord <robinvandernoord@gmail.com>
6
6
  #
7
7
  # SPDX-License-Identifier: MIT
8
- __version__ = "4.4.4"
8
+ __version__ = "4.4.6"
@@ -577,7 +577,7 @@ def memoize(
577
577
  return cached, "cached"
578
578
  # Cache miss - compute result
579
579
 
580
- def track_execute(_qb: "QueryBuilder[t.Any]", raw: Rows):
580
+ def track_execute(_qb: "QueryBuilder[t.Any]", raw: Rows) -> None:
581
581
  # find dependant table+id combinations, includes relationships:
582
582
  deps.update(_determine_dependencies_auto(raw))
583
583
 
@@ -9,13 +9,11 @@ from pathlib import Path
9
9
 
10
10
  import tomli
11
11
  from configuraptor import TypedConfig, alias
12
- from configuraptor.helpers import find_pyproject_toml
12
+ from configuraptor.helpers import expand_env_vars_into_toml_values, find_pyproject_toml
13
13
  from dotenv import dotenv_values, find_dotenv
14
14
 
15
15
  from .types import AnyDict
16
16
 
17
- from configuraptor.helpers import expand_env_vars_into_toml_values
18
-
19
17
  if t.TYPE_CHECKING:
20
18
  from edwh_migrate import Config as MigrateConfig
21
19
  from pydal2sql.typer_support import Config as P2SConfig
@@ -106,27 +106,27 @@ def evaluate_forward_reference(
106
106
  return evaluate_forward_reference_314(fw_ref, namespace=namespace or {})
107
107
 
108
108
 
109
- def resolve_annotation_313(ftype: str) -> type: # pragma: no cover
109
+ def resolve_annotation_313(ftype: str, namespace: dict[str, type] | None = None) -> type: # pragma: no cover
110
110
  """
111
111
  Resolve an annotation that's in string representation.
112
112
 
113
113
  Variant for Python 3.13
114
114
  """
115
115
  fw_ref: ForwardRef = t.get_args(t.Type[ftype])[0]
116
- return evaluate_forward_reference(fw_ref)
116
+ return evaluate_forward_reference(fw_ref, namespace=namespace)
117
117
 
118
118
 
119
- def resolve_annotation_314(ftype: str) -> type: # pragma: no cover
119
+ def resolve_annotation_314(ftype: str, namespace: dict[str, type] | None = None) -> type: # pragma: no cover
120
120
  """
121
121
  Resolve an annotation that's in string representation.
122
122
 
123
123
  Variant for Python 3.14 + using annotationlib
124
124
  """
125
125
  fw_ref = ForwardRef(ftype)
126
- return evaluate_forward_reference(fw_ref)
126
+ return evaluate_forward_reference(fw_ref, namespace=namespace)
127
127
 
128
128
 
129
- def resolve_annotation(ftype: str) -> type: # pragma: no cover
129
+ def resolve_annotation(ftype: str, namespace: dict[str, type] | None = None) -> type: # pragma: no cover
130
130
  """
131
131
  Resolve an annotation that's in string representation.
132
132
 
@@ -135,9 +135,9 @@ def resolve_annotation(ftype: str) -> type: # pragma: no cover
135
135
  if sys.version_info.major != 3:
136
136
  raise EnvironmentError("Only python 3 is supported.")
137
137
  elif sys.version_info.minor <= 13:
138
- return resolve_annotation_313(ftype)
138
+ return resolve_annotation_313(ftype, namespace=namespace)
139
139
  else:
140
- return resolve_annotation_314(ftype)
140
+ return resolve_annotation_314(ftype, namespace=namespace)
141
141
 
142
142
 
143
143
  class TypeDAL(pydal.DAL):
@@ -133,13 +133,13 @@ class TableDefinitionBuilder:
133
133
  """Convert Python type annotation to pydal field type string."""
134
134
  ftype = t.cast(type, ftype_annotation) # cast from Type to type to make mypy happy)
135
135
 
136
+ known_classes = {table.__name__: table for table in self.class_map.values()}
137
+
136
138
  if isinstance(ftype, str):
137
139
  # extract type from string
138
- ftype = resolve_annotation(ftype)
140
+ ftype = resolve_annotation(ftype, namespace=known_classes)
139
141
 
140
142
  if isinstance(ftype, ForwardRef):
141
- known_classes = {table.__name__: table for table in self.class_map.values()}
142
-
143
143
  ftype = evaluate_forward_reference(ftype, namespace=known_classes)
144
144
 
145
145
  if mapping := BASIC_MAPPINGS.get(ftype):
@@ -116,7 +116,7 @@ class HAS_UNIQUE_SLUG(IS_NOT_IN_DB):
116
116
  if not value.strip():
117
117
  raise ValidationError(self.translator(self.error_message))
118
118
 
119
- (tablename, fieldname) = str(self.field).split(".")
119
+ tablename, fieldname = str(self.field).split(".")
120
120
  table = self.dbset.db[tablename]
121
121
  field = table[fieldname]
122
122
  query = field == value
@@ -555,7 +555,7 @@ class QueryBuilder(t.Generic[T_MetaInstance]):
555
555
  for fn_before in db._before_execute:
556
556
  fn_before(self)
557
557
 
558
- rows = db(query).select(*select_args, **select_kwargs)
558
+ rows: Rows = db(query).select(*select_args, **select_kwargs)
559
559
 
560
560
  for fn_after in db._after_execute:
561
561
  fn_after(self, rows)
@@ -1133,11 +1133,16 @@ class QueryBuilder(t.Generic[T_MetaInstance]):
1133
1133
 
1134
1134
  Also adds paginate, since it would be a waste to select more rows than needed.
1135
1135
  """
1136
- if row := self.paginate(page=1, limit=1, verbose=verbose).first():
1137
- return self.model.from_row(row)
1138
- else:
1136
+ row = self.paginate(page=1, limit=1, verbose=verbose).first()
1137
+ if not row:
1139
1138
  return None
1140
1139
 
1140
+ if not isinstance(self.model, TableMeta):
1141
+ # old-style pydal table: keep pydal semantics and return raw Row
1142
+ return row
1143
+
1144
+ return self.model.from_row(row)
1145
+
1141
1146
  def _first(self) -> str:
1142
1147
  return self._paginate(page=1, limit=1)
1143
1148
 
@@ -5,17 +5,18 @@ Contains base functionality related to Relationships.
5
5
  import inspect
6
6
  import typing as t
7
7
  import warnings
8
+ from typing import ForwardRef
8
9
 
9
10
  import pydal.objects
10
11
 
11
12
  from .config import LazyPolicy
12
13
  from .constants import JOIN_OPTIONS
13
- from .core import TypeDAL
14
+ from .core import TypeDAL, evaluate_forward_reference
14
15
  from .fields import TypedField
15
16
  from .helpers import extract_type_optional, looks_like, unwrap_type
16
17
  from .types import Condition, OnQuery, T_Field
17
18
 
18
- To_Type = t.TypeVar("To_Type")
19
+ To_Type = t.TypeVar("To_Type", bound="TypedTable")
19
20
 
20
21
 
21
22
  # default lazy policy is defined at the TypeDAL() instance settings level
@@ -63,6 +64,10 @@ class Relationship(t.Generic[To_Type]):
63
64
  self.condition_and = condition_and
64
65
  self._lazy = lazy
65
66
 
67
+ if t.get_origin(_type) == Ref:
68
+ # unwrap Ref["City"] to ForwardRef("City") to be evaluated/stringified later on:
69
+ _type = t.get_args(_type)[0]
70
+
66
71
  if args := t.get_args(_type):
67
72
  self.table = unwrap_type(args[0])
68
73
  self.multiple = True
@@ -70,6 +75,12 @@ class Relationship(t.Generic[To_Type]):
70
75
  self.table = t.cast(type[TypedTable], _type)
71
76
  self.multiple = False
72
77
 
78
+ if isinstance(self.table, ForwardRef):
79
+ try:
80
+ self.table = evaluate_forward_reference(self.table)
81
+ except Exception:
82
+ self.table = self.table.__forward_arg__
83
+
73
84
  if isinstance(self.table, str):
74
85
  self.table = TypeDAL.to_snake(self.table)
75
86
 
@@ -264,6 +275,18 @@ class Relationship(t.Generic[To_Type]):
264
275
  return fallback_value
265
276
 
266
277
 
278
+ class Ref(t.Generic[To_Type]):
279
+ """
280
+ Type-level forward reference wrapper.
281
+
282
+ Allows writing:
283
+
284
+ relationship(Ref["User"])
285
+
286
+ so that type checkers resolve the inner type correctly.
287
+ """
288
+
289
+
267
290
  @t.overload
268
291
  def relationship(
269
292
  _type: type[list[To_Type]],
@@ -286,7 +309,7 @@ def relationship(
286
309
 
287
310
  @t.overload
288
311
  def relationship(
289
- _type: t.Type[To_Type] | str,
312
+ _type: t.Type[To_Type] | str | t.Type[Ref[To_Type]],
290
313
  condition: Condition = None,
291
314
  *,
292
315
  join: t.Literal["inner"],
@@ -308,7 +331,7 @@ def relationship(
308
331
 
309
332
  @t.overload
310
333
  def relationship(
311
- _type: t.Type[To_Type] | str,
334
+ _type: t.Type[To_Type] | str | t.Type[Ref[To_Type]],
312
335
  condition: Condition = None,
313
336
  join: JOIN_OPTIONS = None,
314
337
  on: OnQuery = None,
@@ -327,7 +350,7 @@ def relationship(
327
350
 
328
351
 
329
352
  def relationship(
330
- _type: type[list[To_Type]] | t.Type[To_Type] | str,
353
+ _type: type[list[To_Type]] | t.Type[To_Type] | str | t.Type[Ref[To_Type]],
331
354
  condition: Condition = None,
332
355
  join: JOIN_OPTIONS = None,
333
356
  on: OnQuery = None,
@@ -526,6 +526,14 @@ def test_minimal_functionality_on_pydal_style_tables():
526
526
  assert qb2
527
527
  assert len(qb2) == 1
528
528
 
529
+ first = QueryBuilder(db.test_query_table).where(number=2).first()
530
+ assert first
531
+ assert first.id == qb1.first().id
532
+
533
+ first_or_fail = QueryBuilder(db.test_query_table).where(number=2).first_or_fail()
534
+ assert first_or_fail
535
+ assert first_or_fail.id == qb1.first().id
536
+
529
537
 
530
538
  def test_before_after_collect(capsys):
531
539
  _setup_data()
@@ -8,7 +8,7 @@ from uuid import uuid4
8
8
 
9
9
  import pytest
10
10
 
11
- from src.typedal import Relationship, TypeDAL, TypedField, TypedTable, relationship
11
+ from src.typedal import Relationship, TypeDAL, TypedField, TypedRows, TypedTable, relationship
12
12
  from src.typedal.caching import (
13
13
  _TypedalCache,
14
14
  _TypedalCacheDependency,
@@ -16,8 +16,8 @@ from src.typedal.caching import (
16
16
  clear_expired,
17
17
  remove_cache,
18
18
  )
19
+ from src.typedal.relationships import Ref
19
20
  from src.typedal.serializers import as_json
20
- from typedal import TypedRows
21
21
 
22
22
  db = TypeDAL("sqlite:memory", lazy_policy="warn")
23
23
 
@@ -970,7 +970,8 @@ class Office(TypedTable):
970
970
  city_id: City
971
971
  company: "Company"
972
972
 
973
- city_alternative = relationship(City, lambda office, city: office.city_id == city.id)
973
+ city = relationship(City, lambda office, city: office.city_id == city.id)
974
+ city_alternative = relationship(Ref["City"], lambda office, city: office.city_id == city.id)
974
975
 
975
976
 
976
977
  class Company(TypedTable):
@@ -1 +0,0 @@
1
- *
Binary file
Binary file
typedal-4.4.4/.crush/init DELETED
File without changes
@@ -1,34 +0,0 @@
1
- {"time":"2026-01-26T17:01:48.91963488+01:00","level":"INFO","source":{"function":"github.com/charmbracelet/crush/internal/config.(*catwalkSync).Get.func1","file":"github.com/charmbracelet/crush/internal/config/catwalk.go","line":55},"msg":"Fetching providers from Catwalk"}
2
- {"time":"2026-01-26T17:01:49.11634171+01:00","level":"INFO","source":{"function":"github.com/charmbracelet/crush/internal/config.(*catwalkSync).Get.func1","file":"github.com/charmbracelet/crush/internal/config/catwalk.go","line":63},"msg":"Catwalk providers not modified"}
3
- {"time":"2026-01-26T17:01:49.116589785+01:00","level":"WARN","source":{"function":"github.com/charmbracelet/crush/internal/config.(*Config).configureProviders","file":"github.com/charmbracelet/crush/internal/config/load.go","line":259},"msg":"Skipping provider due to missing API key","provider":"anthropic"}
4
- {"time":"2026-01-26T17:01:49.128053484+01:00","level":"INFO","msg":"OK 20250424200609_initial.sql (1.26ms)"}
5
- {"time":"2026-01-26T17:01:49.128338994+01:00","level":"INFO","msg":"OK 20250515105448_add_summary_message_id.sql (263.8µs)"}
6
- {"time":"2026-01-26T17:01:49.128678839+01:00","level":"INFO","msg":"OK 20250624000000_add_created_at_indexes.sql (325.2µs)"}
7
- {"time":"2026-01-26T17:01:49.128984204+01:00","level":"INFO","msg":"OK 20250627000000_add_provider_to_messages.sql (291.73µs)"}
8
- {"time":"2026-01-26T17:01:49.12929983+01:00","level":"INFO","msg":"OK 20250810000000_add_is_summary_message.sql (265.62µs)"}
9
- {"time":"2026-01-26T17:01:49.129580779+01:00","level":"INFO","msg":"OK 20250812000000_add_todos_to_sessions.sql (268.09µs)"}
10
- {"time":"2026-01-26T17:01:49.129585964+01:00","level":"INFO","msg":"goose: successfully migrated database to version: 20250812000000"}
11
- {"time":"2026-01-26T17:01:49.129633405+01:00","level":"INFO","source":{"function":"github.com/charmbracelet/crush/internal/app.(*App).initLSPClients","file":"github.com/charmbracelet/crush/internal/app/lsp.go","line":21},"msg":"LSP clients initialization started in background"}
12
- {"time":"2026-01-26T17:01:49.129745943+01:00","level":"INFO","source":{"function":"github.com/charmbracelet/crush/internal/app.New.func1","file":"github.com/charmbracelet/crush/internal/app/app.go","line":110},"msg":"Initializing MCP clients"}
13
- {"time":"2026-01-26T17:01:49.73470854+01:00","level":"INFO","source":{"function":"github.com/charmbracelet/crush/internal/app.(*App).createAndStartLSPClient","file":"github.com/charmbracelet/crush/internal/app/lsp.go","line":76},"msg":"LSP client initialized","name":"python"}
14
- {"time":"2026-01-26T17:02:52.962674187+01:00","level":"INFO","source":{"function":"github.com/charmbracelet/crush/internal/agent.(*sessionAgent).generateTitle","file":"github.com/charmbracelet/crush/internal/agent/agent.go","line":788},"msg":"generated title with small model"}
15
- {"time":"2026-01-26T17:02:57.3247847+01:00","level":"WARN","source":{"function":"github.com/charmbracelet/crush/internal/agent/tools.init.func1","file":"github.com/charmbracelet/crush/internal/agent/tools/rg.go","line":18},"msg":"Ripgrep (rg) not found in $PATH. Some grep features might be limited or slower."}
16
- {"time":"2026-01-26T17:11:33.432259554+01:00","level":"INFO","source":{"function":"github.com/charmbracelet/crush/internal/app.(*App).Shutdown.func1","file":"github.com/charmbracelet/crush/internal/app/app.go","line":507},"msg":"Shutdown took 6.286852ms"}
17
- {"time":"2026-01-26T17:11:33.982816329+01:00","level":"INFO","source":{"function":"github.com/charmbracelet/crush/internal/config.(*catwalkSync).Get.func1","file":"github.com/charmbracelet/crush/internal/config/catwalk.go","line":55},"msg":"Fetching providers from Catwalk"}
18
- {"time":"2026-01-26T17:11:34.178260248+01:00","level":"INFO","source":{"function":"github.com/charmbracelet/crush/internal/config.(*catwalkSync).Get.func1","file":"github.com/charmbracelet/crush/internal/config/catwalk.go","line":63},"msg":"Catwalk providers not modified"}
19
- {"time":"2026-01-26T17:11:34.178494988+01:00","level":"WARN","source":{"function":"github.com/charmbracelet/crush/internal/config.(*Config).configureProviders","file":"github.com/charmbracelet/crush/internal/config/load.go","line":259},"msg":"Skipping provider due to missing API key","provider":"anthropic"}
20
- {"time":"2026-01-26T17:11:34.18216688+01:00","level":"INFO","msg":"goose: no migrations to run. current version: 20250812000000"}
21
- {"time":"2026-01-26T17:11:34.182194111+01:00","level":"INFO","source":{"function":"github.com/charmbracelet/crush/internal/app.(*App).initLSPClients","file":"github.com/charmbracelet/crush/internal/app/lsp.go","line":21},"msg":"LSP clients initialization started in background"}
22
- {"time":"2026-01-26T17:11:34.182335616+01:00","level":"INFO","source":{"function":"github.com/charmbracelet/crush/internal/app.New.func1","file":"github.com/charmbracelet/crush/internal/app/app.go","line":110},"msg":"Initializing MCP clients"}
23
- {"time":"2026-01-26T17:11:34.786142525+01:00","level":"INFO","source":{"function":"github.com/charmbracelet/crush/internal/app.(*App).createAndStartLSPClient","file":"github.com/charmbracelet/crush/internal/app/lsp.go","line":76},"msg":"LSP client initialized","name":"python"}
24
- {"time":"2026-01-26T17:11:43.042930337+01:00","level":"INFO","source":{"function":"github.com/charmbracelet/crush/internal/agent.(*sessionAgent).generateTitle","file":"github.com/charmbracelet/crush/internal/agent/agent.go","line":788},"msg":"generated title with small model"}
25
- {"time":"2026-01-26T17:11:44.911909276+01:00","level":"WARN","source":{"function":"github.com/charmbracelet/crush/internal/agent/tools.init.func1","file":"github.com/charmbracelet/crush/internal/agent/tools/rg.go","line":18},"msg":"Ripgrep (rg) not found in $PATH. Some grep features might be limited or slower."}
26
- {"time":"2026-01-26T18:10:19.727802782+01:00","level":"INFO","source":{"function":"github.com/charmbracelet/crush/internal/config.(*catwalkSync).Get.func1","file":"github.com/charmbracelet/crush/internal/config/catwalk.go","line":55},"msg":"Fetching providers from Catwalk"}
27
- {"time":"2026-01-26T18:10:19.926137244+01:00","level":"INFO","source":{"function":"github.com/charmbracelet/crush/internal/config.(*catwalkSync).Get.func1","file":"github.com/charmbracelet/crush/internal/config/catwalk.go","line":63},"msg":"Catwalk providers not modified"}
28
- {"time":"2026-01-26T18:10:19.926245207+01:00","level":"WARN","source":{"function":"github.com/charmbracelet/crush/internal/config.(*Config).configureProviders","file":"github.com/charmbracelet/crush/internal/config/load.go","line":259},"msg":"Skipping provider due to missing API key","provider":"anthropic"}
29
- {"time":"2026-01-26T18:10:19.927808467+01:00","level":"INFO","msg":"goose: no migrations to run. current version: 20250812000000"}
30
- {"time":"2026-01-26T18:10:19.927858435+01:00","level":"INFO","source":{"function":"github.com/charmbracelet/crush/internal/app.(*App).initLSPClients","file":"github.com/charmbracelet/crush/internal/app/lsp.go","line":21},"msg":"LSP clients initialization started in background"}
31
- {"time":"2026-01-26T18:10:19.928006013+01:00","level":"INFO","source":{"function":"github.com/charmbracelet/crush/internal/app.New.func1","file":"github.com/charmbracelet/crush/internal/app/app.go","line":110},"msg":"Initializing MCP clients"}
32
- {"time":"2026-01-26T18:10:20.546676356+01:00","level":"INFO","source":{"function":"github.com/charmbracelet/crush/internal/app.(*App).createAndStartLSPClient","file":"github.com/charmbracelet/crush/internal/app/lsp.go","line":76},"msg":"LSP client initialized","name":"python"}
33
- {"time":"2026-01-26T18:11:07.640056766+01:00","level":"INFO","source":{"function":"github.com/charmbracelet/crush/internal/agent.(*sessionAgent).generateTitle","file":"github.com/charmbracelet/crush/internal/agent/agent.go","line":788},"msg":"generated title with small model"}
34
- {"time":"2026-01-26T18:11:11.201161834+01:00","level":"WARN","source":{"function":"github.com/charmbracelet/crush/internal/agent/tools.init.func1","file":"github.com/charmbracelet/crush/internal/agent/tools/rg.go","line":18},"msg":"Ripgrep (rg) not found in $PATH. Some grep features might be limited or slower."}
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes