redis-lua-py 0.12.0__tar.gz → 0.13.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.
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/.github/workflows/ci.yml +3 -0
- redis_lua_py-0.13.0/.release-please-manifest.json +3 -0
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/CHANGELOG.md +7 -0
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/PKG-INFO +2 -2
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/README.md +1 -1
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/docs/examples.md +2 -2
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/docs/guide/build-time.md +7 -1
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/docs/guide/generated-lua.md +1 -1
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/docs/guide/keys-and-arguments.md +17 -8
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/docs/guide/redis-functions.md +1 -1
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/docs/installation.md +2 -1
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/docs/quickstart.md +1 -1
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/pyproject.toml +5 -1
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/src/redis_lua_py/__init__.py +1 -1
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/src/redis_lua_py/_compile/analysis.py +27 -1
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/src/redis_lua_py/_compile/base.py +7 -0
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/src/redis_lua_py/_compile/scope.py +17 -3
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/src/redis_lua_py/_portable.py +11 -6
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/src/redis_lua_py/codegen.py +1 -1
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/tests/generated_scripts.py +8 -8
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/tests/test_binary.py +2 -1
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/tests/test_codegen.py +117 -0
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/tests/test_compile.py +21 -1
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/tests/test_expressions.py +14 -0
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/tests/test_semantics.py +29 -0
- redis_lua_py-0.12.0/.release-please-manifest.json +0 -3
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/.github/assets/README.md +0 -0
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/.github/assets/banner-dark.svg +0 -0
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/.github/assets/banner-light.svg +0 -0
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/.github/assets/generate.py +0 -0
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/.github/assets/logo.svg +0 -0
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/.github/assets/logomark.svg +0 -0
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/.github/assets/social-preview.png +0 -0
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/.github/assets/social-preview.svg +0 -0
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/.github/workflows/docs.yml +0 -0
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/.github/workflows/pr-title.yml +0 -0
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/.github/workflows/release.yml +0 -0
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/.gitignore +0 -0
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/.python-version +0 -0
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/CONTRIBUTING.md +0 -0
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/LICENSE +0 -0
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/docs/assets/logo.svg +0 -0
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/docs/contributing.md +0 -0
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/docs/development.md +0 -0
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/docs/guide/async.md +0 -0
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/docs/guide/binary-values.md +0 -0
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/docs/guide/binding-a-client.md +0 -0
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/docs/guide/calling-redis-commands.md +0 -0
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/docs/guide/constants.md +0 -0
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/docs/guide/return-values.md +0 -0
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/docs/guide/testing.md +0 -0
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/docs/index.md +0 -0
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/docs/reference/api.md +0 -0
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/docs/reference/errors.md +0 -0
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/docs/reference/lua-vs-python.md +0 -0
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/docs/reference/supported-subset.md +0 -0
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/docs/stylesheets/extra.css +0 -0
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/release-please-config.json +0 -0
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/scripts/generate_commands.py +0 -0
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/src/redis_lua_py/__main__.py +0 -0
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/src/redis_lua_py/_command_stubs.py +0 -0
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/src/redis_lua_py/_commands.py +0 -0
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/src/redis_lua_py/_compile/__init__.py +0 -0
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/src/redis_lua_py/_compile/calls.py +0 -0
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/src/redis_lua_py/_compile/control.py +0 -0
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/src/redis_lua_py/_compile/expressions.py +0 -0
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/src/redis_lua_py/_compile/helpers.py +0 -0
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/src/redis_lua_py/_compile/source.py +0 -0
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/src/redis_lua_py/_compile/statements.py +0 -0
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/src/redis_lua_py/_compile/tables.py +0 -0
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/src/redis_lua_py/_library.py +0 -0
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/src/redis_lua_py/_lua.py +0 -0
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/src/redis_lua_py/_runtime.py +0 -0
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/src/redis_lua_py/_script.py +0 -0
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/src/redis_lua_py/errors.py +0 -0
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/src/redis_lua_py/py.typed +0 -0
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/tests/codegen_scripts.py +0 -0
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/tests/conftest.py +0 -0
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/tests/module_with_client.py +0 -0
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/tests/module_without_import.py +0 -0
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/tests/test_binding.py +0 -0
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/tests/test_booleans_and_conversions.py +0 -0
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/tests/test_commands.py +0 -0
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/tests/test_constants.py +0 -0
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/tests/test_control_flow.py +0 -0
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/tests/test_coredis.py +0 -0
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/tests/test_errors.py +0 -0
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/tests/test_execute.py +0 -0
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/tests/test_functions.py +0 -0
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/tests/test_mistranslations.py +0 -0
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/tests/test_namespace.py +0 -0
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/tests/test_strings_and_indexing.py +0 -0
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/tests/test_table_stakes.py +0 -0
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/tests/test_typing.py +0 -0
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/uv.lock +0 -0
- {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/zensical.toml +0 -0
|
@@ -32,6 +32,9 @@ jobs:
|
|
|
32
32
|
with:
|
|
33
33
|
enable-cache: true
|
|
34
34
|
- run: uv sync --dev --python ${{ matrix.python-version }}
|
|
35
|
+
# Generated modules must still run on 3.9, which libraries vendoring
|
|
36
|
+
# them may support; tests/test_codegen.py runs one there when it can.
|
|
37
|
+
- run: uv python install 3.9
|
|
35
38
|
- run: uv run pytest
|
|
36
39
|
|
|
37
40
|
oldest:
|
|
@@ -7,6 +7,13 @@ are written by [release-please](https://github.com/googleapis/release-please)
|
|
|
7
7
|
from the conventional commit subjects on `main`; see
|
|
8
8
|
[CONTRIBUTING.md](CONTRIBUTING.md) for how a release is cut.
|
|
9
9
|
|
|
10
|
+
## [0.13.0](https://github.com/IgnaceMaes/redis-lua-py/compare/v0.12.0...v0.13.0) (2026-09-26)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
* run generated modules on python 3.9 and keep forwarded numbers exact ([#34](https://github.com/IgnaceMaes/redis-lua-py/issues/34)) ([7178211](https://github.com/IgnaceMaes/redis-lua-py/commit/7178211f73e2503188240d62ebe8ab18278bdba2))
|
|
16
|
+
|
|
10
17
|
## [0.12.0](https://github.com/IgnaceMaes/redis-lua-py/compare/v0.11.0...v0.12.0) (2026-09-25)
|
|
11
18
|
|
|
12
19
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: redis-lua-py
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.13.0
|
|
4
4
|
Summary: Write Redis Lua scripts as real Python functions, not strings.
|
|
5
5
|
Project-URL: Homepage, https://ignacemaes.com/redis-lua-py/
|
|
6
6
|
Project-URL: Documentation, https://ignacemaes.com/redis-lua-py/
|
|
@@ -103,7 +103,7 @@ Nothing is hidden. Every script exposes the Lua it produced:
|
|
|
103
103
|
-- Generated by redis-lua-py from src/limits.py:6. Do not edit.
|
|
104
104
|
local key = KEYS[1]
|
|
105
105
|
local limit = tonumber(ARGV[1])
|
|
106
|
-
local ttl =
|
|
106
|
+
local ttl = ARGV[2]
|
|
107
107
|
local current = redis.call('INCR', key)
|
|
108
108
|
if current == 1 then
|
|
109
109
|
redis.call('EXPIRE', key, ttl)
|
|
@@ -78,7 +78,7 @@ Nothing is hidden. Every script exposes the Lua it produced:
|
|
|
78
78
|
-- Generated by redis-lua-py from src/limits.py:6. Do not edit.
|
|
79
79
|
local key = KEYS[1]
|
|
80
80
|
local limit = tonumber(ARGV[1])
|
|
81
|
-
local ttl =
|
|
81
|
+
local ttl = ARGV[2]
|
|
82
82
|
local current = redis.call('INCR', key)
|
|
83
83
|
if current == 1 then
|
|
84
84
|
redis.call('EXPIRE', key, ttl)
|
|
@@ -42,8 +42,8 @@ def claim_jobs(queue: Key, processing: Key, now: int, limit: int) -> list[bytes]
|
|
|
42
42
|
```lua
|
|
43
43
|
local queue = KEYS[1]
|
|
44
44
|
local processing = KEYS[2]
|
|
45
|
-
local now =
|
|
46
|
-
local limit =
|
|
45
|
+
local now = ARGV[1]
|
|
46
|
+
local limit = ARGV[2]
|
|
47
47
|
local ids = redis.call('ZRANGEBYSCORE', queue, 0, now, 'LIMIT', 0, limit)
|
|
48
48
|
local claimed = {}
|
|
49
49
|
for __i1 = 1, #ids do
|
|
@@ -12,6 +12,12 @@ develop. What you ship is that module: plain Python that needs only the
|
|
|
12
12
|
standard library, with a typed function per script, called exactly the way
|
|
13
13
|
the `@script` would be.
|
|
14
14
|
|
|
15
|
+
redis-lua-py itself needs Python 3.10 to generate the module, but what it
|
|
16
|
+
generates runs on Python 3.9 as well, so a library that still supports 3.9 can
|
|
17
|
+
ship it. Its annotations may use `X | Y`, since `from __future__ import
|
|
18
|
+
annotations` keeps them from being evaluated; everything that runs is 3.9
|
|
19
|
+
code.
|
|
20
|
+
|
|
15
21
|
## Generate a module
|
|
16
22
|
|
|
17
23
|
Keep the scripts outside the package you ship, and redis-lua-py in your
|
|
@@ -68,7 +74,7 @@ RATE_LIMIT = """\
|
|
|
68
74
|
-- Generated by redis-lua-py from redis_scripts/limits.py:6. Do not edit.
|
|
69
75
|
local key = KEYS[1]
|
|
70
76
|
local limit = tonumber(ARGV[1])
|
|
71
|
-
local ttl =
|
|
77
|
+
local ttl = ARGV[2]
|
|
72
78
|
local current = redis.call('INCR', key)
|
|
73
79
|
if current == 1 then
|
|
74
80
|
redis.call('EXPIRE', key, ttl)
|
|
@@ -11,7 +11,7 @@ Nothing is hidden. Every script exposes the Lua it produced:
|
|
|
11
11
|
-- Generated by redis-lua-py from src/limits.py:6. Do not edit.
|
|
12
12
|
local key = KEYS[1]
|
|
13
13
|
local limit = tonumber(ARGV[1])
|
|
14
|
-
local ttl =
|
|
14
|
+
local ttl = ARGV[2]
|
|
15
15
|
local current = redis.call('INCR', key)
|
|
16
16
|
if current == 1 then
|
|
17
17
|
redis.call('EXPIRE', key, ttl)
|
|
@@ -11,7 +11,7 @@ def rate_limit(key: Key, limit: int, ttl: int) -> int: ...
|
|
|
11
11
|
```lua
|
|
12
12
|
local key = KEYS[1]
|
|
13
13
|
local limit = tonumber(ARGV[1])
|
|
14
|
-
local ttl =
|
|
14
|
+
local ttl = ARGV[2]
|
|
15
15
|
```
|
|
16
16
|
|
|
17
17
|
!!! warning "Annotate every key"
|
|
@@ -28,18 +28,27 @@ to it on the way in.
|
|
|
28
28
|
| Annotation | In the script |
|
|
29
29
|
| --- | --- |
|
|
30
30
|
| `Key` | `KEYS[n]`, and what the cluster routes on |
|
|
31
|
-
| `int`, `float` | wrapped in `tonumber`, so it is a number by the time your comparison runs |
|
|
31
|
+
| `int`, `float` | wrapped in `tonumber`, so it is a number by the time your comparison runs; left as text when the body only hands it to Redis |
|
|
32
32
|
| `str` | passed through as the string it already is |
|
|
33
33
|
| `bool` | encoded as `"1"` or `"0"`, and a Lua boolean in the body |
|
|
34
34
|
| `bytes`, `memoryview` | passed through untouched — see [Binary values](binary-values.md) |
|
|
35
35
|
|
|
36
|
+
### Numbers only handed to Redis stay text
|
|
37
|
+
|
|
38
|
+
In `rate_limit`, `ttl` is never compared or added to, only passed to `EXPIRE`,
|
|
39
|
+
so it is left as the text it arrived as: `local ttl = ARGV[2]`. A Lua number is
|
|
40
|
+
a double, which rounds an integer past 2^53, and which older servers write back
|
|
41
|
+
with 17 digits, `0.1` as `0.10000000000000001`. Left as text, the value reaches
|
|
42
|
+
Redis exactly as the caller passed it. The parameter is still an `int` to the
|
|
43
|
+
caller, and it is converted as soon as the body does anything else with it.
|
|
44
|
+
|
|
36
45
|
### `float` carries a caveat
|
|
37
46
|
|
|
38
|
-
Annotate `float` for arithmetic, not for a value you mean to
|
|
39
|
-
|
|
40
|
-
text with `%.14g`, so a value with more significant digits than that
|
|
41
|
-
come back as it went in. Annotate `str` and call `str()` at the call
|
|
42
|
-
the value is only being carried.
|
|
47
|
+
Annotate `float` for arithmetic, not for a value you mean to return unchanged.
|
|
48
|
+
Unless the body only hands it to Redis, it is a Lua number, and Lua renders a
|
|
49
|
+
number back to text with `%.14g`, so a value with more significant digits than that
|
|
50
|
+
does not come back as it went in. Annotate `str` and call `str()` at the call
|
|
51
|
+
site when the value is only being carried.
|
|
43
52
|
|
|
44
53
|
### `bool` is a boolean in the body
|
|
45
54
|
|
|
@@ -70,7 +79,7 @@ delete_tagged(client, tag="purged", keys=["a", "b", "c"], stamp=1700000000)
|
|
|
70
79
|
|
|
71
80
|
```lua
|
|
72
81
|
local tag = KEYS[1]
|
|
73
|
-
local stamp =
|
|
82
|
+
local stamp = ARGV[1]
|
|
74
83
|
local keys = {}
|
|
75
84
|
for __i1 = 2, #KEYS do
|
|
76
85
|
keys[#keys + 1] = KEYS[__i1]
|
|
@@ -82,7 +82,7 @@ redis.register_function{
|
|
|
82
82
|
function_name = 'hit',
|
|
83
83
|
callback = function(KEYS, ARGV)
|
|
84
84
|
local key = KEYS[1]
|
|
85
|
-
local ttl =
|
|
85
|
+
local ttl = ARGV[1]
|
|
86
86
|
local current = redis.call('INCR', key)
|
|
87
87
|
if current == 1 then
|
|
88
88
|
redis.call('EXPIRE', key, ttl)
|
|
@@ -12,7 +12,8 @@ pip install redis-lua-py
|
|
|
12
12
|
|
|
13
13
|
## Requirements
|
|
14
14
|
|
|
15
|
-
- Python 3.10 or newer
|
|
15
|
+
- Python 3.10 or newer. Modules [generated ahead of time](guide/build-time.md)
|
|
16
|
+
run on Python 3.9 too, without this package installed.
|
|
16
17
|
- A Redis client: [redis-py](https://github.com/redis/redis-py) 4.2 or newer,
|
|
17
18
|
or [coredis](https://github.com/alisaifee/coredis)
|
|
18
19
|
|
|
@@ -56,7 +56,7 @@ print(rate_limit.lua)
|
|
|
56
56
|
-- Generated by redis-lua-py from limits.py:5. Do not edit.
|
|
57
57
|
local key = KEYS[1]
|
|
58
58
|
local limit = tonumber(ARGV[1])
|
|
59
|
-
local ttl =
|
|
59
|
+
local ttl = ARGV[2]
|
|
60
60
|
local current = redis.call('INCR', key)
|
|
61
61
|
if current == 1 then
|
|
62
62
|
redis.call('EXPIRE', key, ttl)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "redis-lua-py"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.13.0"
|
|
4
4
|
description = "Write Redis Lua scripts as real Python functions, not strings."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.10"
|
|
@@ -65,6 +65,10 @@ addopts = "-q"
|
|
|
65
65
|
src = ["src", "tests"]
|
|
66
66
|
line-length = 100
|
|
67
67
|
target-version = "py310"
|
|
68
|
+
# Generated modules must import on Python 3.9, which libraries that vendor them
|
|
69
|
+
# may still support. Linting what they are built from as 3.9 code keeps a
|
|
70
|
+
# 3.10-only construct out of them, and keeps ruff from asking for one.
|
|
71
|
+
per-file-target-version = { "src/redis_lua_py/_portable.py" = "py39", "tests/generated_scripts.py" = "py39" }
|
|
68
72
|
|
|
69
73
|
[tool.ruff.lint]
|
|
70
74
|
select = ["E", "F", "I", "UP", "B", "SIM", "RUF", "N", "C4", "PT"]
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import ast
|
|
6
|
-
from collections.abc import Iterator
|
|
6
|
+
from collections.abc import Callable, Iterator
|
|
7
7
|
|
|
8
8
|
from .. import _lua as lua
|
|
9
9
|
|
|
@@ -134,6 +134,32 @@ def walk_scope(root: ast.AST) -> Iterator[ast.AST]:
|
|
|
134
134
|
pending.extend(ast.iter_child_nodes(node))
|
|
135
135
|
|
|
136
136
|
|
|
137
|
+
def only_forwarded(
|
|
138
|
+
root: ast.FunctionDef, name: str, is_command: Callable[[ast.Call], bool]
|
|
139
|
+
) -> bool:
|
|
140
|
+
"""True if every use of a name is a whole argument to a Redis command.
|
|
141
|
+
|
|
142
|
+
Such a value is text going back to Redis, whatever it is annotated as.
|
|
143
|
+
Anything else -- arithmetic, a comparison, an assignment to it, a use in
|
|
144
|
+
a nested function that may shadow it -- makes the answer False, as does
|
|
145
|
+
no use at all.
|
|
146
|
+
"""
|
|
147
|
+
forwarded = {
|
|
148
|
+
id(arg)
|
|
149
|
+
for statement in root.body
|
|
150
|
+
for node in ast.walk(statement)
|
|
151
|
+
if isinstance(node, ast.Call) and is_command(node)
|
|
152
|
+
for arg in node.args
|
|
153
|
+
}
|
|
154
|
+
uses = [
|
|
155
|
+
node
|
|
156
|
+
for statement in root.body
|
|
157
|
+
for node in ast.walk(statement)
|
|
158
|
+
if isinstance(node, ast.Name) and node.id == name
|
|
159
|
+
]
|
|
160
|
+
return bool(uses) and all(id(use) in forwarded for use in uses)
|
|
161
|
+
|
|
162
|
+
|
|
137
163
|
def target_names(target: ast.expr) -> list[str]:
|
|
138
164
|
"""The names an assignment or loop target binds."""
|
|
139
165
|
if isinstance(target, ast.Name):
|
|
@@ -296,6 +296,13 @@ class CompilerBase(ABC):
|
|
|
296
296
|
return "str"
|
|
297
297
|
if name in {"int", "float", "tonumber", "len", "abs", "ord"}:
|
|
298
298
|
return "num"
|
|
299
|
+
if name in {"min", "max"} and node.args:
|
|
300
|
+
# Python also takes the least of strings, or of one list,
|
|
301
|
+
# so only numbers in make a number out.
|
|
302
|
+
sides = {self.kind(arg) for arg in node.args}
|
|
303
|
+
if sides <= {"num", "?"}:
|
|
304
|
+
return "?" if sides == {"?"} else "num"
|
|
305
|
+
return None
|
|
299
306
|
return "num" if self.math_attr(node.func) is not None else None
|
|
300
307
|
case ast.Call(func=ast.Attribute(value=receiver, attr=attr)):
|
|
301
308
|
if self.math_attr(node.func) is not None:
|
|
@@ -5,8 +5,9 @@ from __future__ import annotations
|
|
|
5
5
|
import ast
|
|
6
6
|
|
|
7
7
|
from .. import _lua as lua
|
|
8
|
-
from .analysis import declares_exactly, target_names, walk_scope
|
|
8
|
+
from .analysis import declares_exactly, only_forwarded, target_names, walk_scope
|
|
9
9
|
from .base import CompilerBase
|
|
10
|
+
from .tables import REDIS_DIRECT
|
|
10
11
|
|
|
11
12
|
|
|
12
13
|
class ScopeCompiler(CompilerBase):
|
|
@@ -141,9 +142,12 @@ class ScopeCompiler(CompilerBase):
|
|
|
141
142
|
source = lua.Index(lua.Global("ARGV"), lua.Num(len(self.args)))
|
|
142
143
|
if self._is_numeric(arg.annotation):
|
|
143
144
|
# ARGV always arrives as strings; an int/float annotation
|
|
144
|
-
# is the author asking for the conversion.
|
|
145
|
+
# is the author asking for the conversion. Unless the value
|
|
146
|
+
# only goes back to Redis: a Lua number is a double, so
|
|
147
|
+
# converting would round an integer past 2^53 on the way.
|
|
145
148
|
self.numeric_args.add(name)
|
|
146
|
-
|
|
149
|
+
if not only_forwarded(self.func, name, self._is_command):
|
|
150
|
+
source = lua.Call(lua.Global("tonumber"), (source,))
|
|
147
151
|
elif self._annotation_name(arg.annotation) == "bool":
|
|
148
152
|
# A bool arrives as "1" or "0", and "0" is a string Lua and
|
|
149
153
|
# Python both count as true. Comparing makes it a boolean.
|
|
@@ -185,6 +189,16 @@ class ScopeCompiler(CompilerBase):
|
|
|
185
189
|
return node.value.rsplit(".", 1)[-1].split("[", 1)[0]
|
|
186
190
|
return None
|
|
187
191
|
|
|
192
|
+
def _is_command(self, node: ast.Call) -> bool:
|
|
193
|
+
"""A call that sends its arguments to Redis as they are."""
|
|
194
|
+
func = node.func
|
|
195
|
+
return (
|
|
196
|
+
isinstance(func, ast.Attribute)
|
|
197
|
+
and isinstance(func.value, ast.Name)
|
|
198
|
+
and self.namespace_kind(func.value.id) == "redis"
|
|
199
|
+
and (func.attr not in REDIS_DIRECT or func.attr in {"call", "pcall"})
|
|
200
|
+
)
|
|
201
|
+
|
|
188
202
|
def _is_key(self, node: ast.expr | None) -> bool:
|
|
189
203
|
return self._annotation_name(node) == "Key"
|
|
190
204
|
|
|
@@ -8,19 +8,24 @@ stay in step: there is only one copy of this code to get right.
|
|
|
8
8
|
So this module imports only the standard library, and nothing from the
|
|
9
9
|
package, and every name it defines starts with an underscore, to stay out of
|
|
10
10
|
the way of the scripts in a generated module.
|
|
11
|
+
|
|
12
|
+
It is also the one module here that runs on Python 3.9, because a library
|
|
13
|
+
that vendors generated code may still support it. Annotations are never
|
|
14
|
+
evaluated, so they can use ``X | Y``; anything evaluated cannot, which is why
|
|
15
|
+
the aliases below spell ``Union`` and ``isinstance`` takes tuples.
|
|
11
16
|
"""
|
|
12
17
|
|
|
13
18
|
from __future__ import annotations
|
|
14
19
|
|
|
15
20
|
from collections.abc import Iterable
|
|
16
|
-
from typing import Any, Protocol
|
|
21
|
+
from typing import Any, Protocol, Union
|
|
17
22
|
from weakref import WeakKeyDictionary
|
|
18
23
|
|
|
19
24
|
#: What a generated signature accepts for a key, the same as redis-py does.
|
|
20
|
-
_Key = str
|
|
25
|
+
_Key = Union[str, bytes, memoryview]
|
|
21
26
|
|
|
22
27
|
#: What a generated signature accepts for an argument it has no better type for.
|
|
23
|
-
_Arg = str
|
|
28
|
+
_Arg = Union[str, bytes, memoryview, int, float]
|
|
24
29
|
|
|
25
30
|
|
|
26
31
|
class _SyncClient(Protocol):
|
|
@@ -61,9 +66,9 @@ def _encode(
|
|
|
61
66
|
"""
|
|
62
67
|
if isinstance(value, bool):
|
|
63
68
|
return "1" if value else "0"
|
|
64
|
-
if isinstance(value, str
|
|
69
|
+
if isinstance(value, (str, bytes, memoryview)):
|
|
65
70
|
return value
|
|
66
|
-
if isinstance(value, int
|
|
71
|
+
if isinstance(value, (int, float)):
|
|
67
72
|
return repr(value) if isinstance(value, float) else str(value)
|
|
68
73
|
raise error(
|
|
69
74
|
f"argument {name!r} is a {type(value).__name__}, which has no Redis representation. "
|
|
@@ -77,7 +82,7 @@ def _items(name: str, value: object, error: type[Exception] = TypeError) -> list
|
|
|
77
82
|
A string is iterable too, and splitting a key into characters is never
|
|
78
83
|
what was meant, so it is refused rather than spread.
|
|
79
84
|
"""
|
|
80
|
-
if isinstance(value, str
|
|
85
|
+
if isinstance(value, (str, bytes, memoryview)) or not isinstance(value, Iterable):
|
|
81
86
|
raise error(
|
|
82
87
|
f"argument {name!r} takes a list, got a {type(value).__name__}. "
|
|
83
88
|
"Wrap a single value in a list."
|
|
@@ -12,7 +12,7 @@ Regenerate with:
|
|
|
12
12
|
from __future__ import annotations
|
|
13
13
|
|
|
14
14
|
from collections.abc import Awaitable, Iterable
|
|
15
|
-
from typing import Any, Protocol, overload
|
|
15
|
+
from typing import Any, Protocol, Union, overload
|
|
16
16
|
from weakref import WeakKeyDictionary
|
|
17
17
|
|
|
18
18
|
__all__ = [
|
|
@@ -30,10 +30,10 @@ __all__ = [
|
|
|
30
30
|
|
|
31
31
|
|
|
32
32
|
#: What a generated signature accepts for a key, the same as redis-py does.
|
|
33
|
-
_Key = str
|
|
33
|
+
_Key = Union[str, bytes, memoryview]
|
|
34
34
|
|
|
35
35
|
#: What a generated signature accepts for an argument it has no better type for.
|
|
36
|
-
_Arg = str
|
|
36
|
+
_Arg = Union[str, bytes, memoryview, int, float]
|
|
37
37
|
|
|
38
38
|
|
|
39
39
|
class _SyncClient(Protocol):
|
|
@@ -74,9 +74,9 @@ def _encode(
|
|
|
74
74
|
"""
|
|
75
75
|
if isinstance(value, bool):
|
|
76
76
|
return "1" if value else "0"
|
|
77
|
-
if isinstance(value, str
|
|
77
|
+
if isinstance(value, (str, bytes, memoryview)):
|
|
78
78
|
return value
|
|
79
|
-
if isinstance(value, int
|
|
79
|
+
if isinstance(value, (int, float)):
|
|
80
80
|
return repr(value) if isinstance(value, float) else str(value)
|
|
81
81
|
raise error(
|
|
82
82
|
f"argument {name!r} is a {type(value).__name__}, which has no Redis representation. "
|
|
@@ -90,7 +90,7 @@ def _items(name: str, value: object, error: type[Exception] = TypeError) -> list
|
|
|
90
90
|
A string is iterable too, and splitting a key into characters is never
|
|
91
91
|
what was meant, so it is refused rather than spread.
|
|
92
92
|
"""
|
|
93
|
-
if isinstance(value, str
|
|
93
|
+
if isinstance(value, (str, bytes, memoryview)) or not isinstance(value, Iterable):
|
|
94
94
|
raise error(
|
|
95
95
|
f"argument {name!r} takes a list, got a {type(value).__name__}. "
|
|
96
96
|
"Wrap a single value in a list."
|
|
@@ -149,7 +149,7 @@ RATE_LIMIT = """\
|
|
|
149
149
|
-- Generated by redis-lua-py from tests/codegen_scripts.py:19. Do not edit.
|
|
150
150
|
local key = KEYS[1]
|
|
151
151
|
local limit = tonumber(ARGV[1])
|
|
152
|
-
local ttl =
|
|
152
|
+
local ttl = ARGV[2]
|
|
153
153
|
local current = redis.call('INCR', key)
|
|
154
154
|
if current == 1 then
|
|
155
155
|
redis.call('EXPIRE', key, ttl)
|
|
@@ -188,7 +188,7 @@ def rate_limit(client: Any, /, key: _Key, limit: int, ttl: int) -> Any:
|
|
|
188
188
|
TOUCH_ALL = """\
|
|
189
189
|
-- touch_all
|
|
190
190
|
-- Generated by redis-lua-py from tests/codegen_scripts.py:29. Do not edit.
|
|
191
|
-
local ttl =
|
|
191
|
+
local ttl = ARGV[1]
|
|
192
192
|
local keys = {}
|
|
193
193
|
for __i1 = 1, #KEYS do
|
|
194
194
|
keys[#keys + 1] = KEYS[__i1]
|
|
@@ -40,7 +40,8 @@ class TestArgv:
|
|
|
40
40
|
"""No tonumber, no tostring: a bytes annotation is a passthrough."""
|
|
41
41
|
emitted = body(append_chunk)
|
|
42
42
|
assert "local chunk = ARGV[1]" in emitted
|
|
43
|
-
|
|
43
|
+
# Only handed back to Redis, so the int is left as text too.
|
|
44
|
+
assert "local ttl = ARGV[2]" in emitted
|
|
44
45
|
|
|
45
46
|
def test_non_utf8_argv_arrives_intact(self, client: Any) -> None:
|
|
46
47
|
size, count = append_chunk(client, buffer="buf", counter="chunks", chunk=BLOB, ttl=60)
|
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import ast
|
|
6
6
|
import importlib.util
|
|
7
7
|
import inspect
|
|
8
|
+
import shutil
|
|
8
9
|
import subprocess
|
|
9
10
|
import sys
|
|
10
11
|
from pathlib import Path
|
|
@@ -199,6 +200,122 @@ class TestGeneratedFunctions:
|
|
|
199
200
|
codegen.generate(module_of(_run=codegen_scripts.rate_limit), tmp_path / "_lua.py")
|
|
200
201
|
|
|
201
202
|
|
|
203
|
+
def python39() -> str | None:
|
|
204
|
+
"""A Python 3.9 interpreter, if uv knows of one. CI installs one for this."""
|
|
205
|
+
uv = shutil.which("uv")
|
|
206
|
+
if uv is None:
|
|
207
|
+
return None
|
|
208
|
+
found = subprocess.run(
|
|
209
|
+
[uv, "python", "find", "3.9"], capture_output=True, text=True, check=False
|
|
210
|
+
)
|
|
211
|
+
if found.returncode != 0:
|
|
212
|
+
return None
|
|
213
|
+
return found.stdout.strip() or None
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
#: Imports a generated module on its own, with nothing else installed.
|
|
217
|
+
LOAD = """
|
|
218
|
+
import importlib.util, sys
|
|
219
|
+
|
|
220
|
+
spec = importlib.util.spec_from_file_location("generated_lua", sys.argv[1])
|
|
221
|
+
module = importlib.util.module_from_spec(spec)
|
|
222
|
+
spec.loader.exec_module(module)
|
|
223
|
+
"""
|
|
224
|
+
|
|
225
|
+
#: Calls the scripts of codegen_scripts through a stand-in for redis-py that
|
|
226
|
+
#: records what it is asked to run. Python 3.9, and standard library only.
|
|
227
|
+
CALL = """
|
|
228
|
+
class Client:
|
|
229
|
+
def __init__(self):
|
|
230
|
+
self.calls = []
|
|
231
|
+
def register_script(self, lua):
|
|
232
|
+
def run(keys, args, client):
|
|
233
|
+
self.calls.append((lua, keys, args))
|
|
234
|
+
return len(self.calls)
|
|
235
|
+
return run
|
|
236
|
+
|
|
237
|
+
client = Client()
|
|
238
|
+
assert module.rate_limit(client, key="k", limit=1, ttl=2) == 1
|
|
239
|
+
assert module.touch_all(client, keys=iter([b"a", "b"]), ttl=2.5) == 2
|
|
240
|
+
assert module.echo(client, True, suffix=memoryview(b"x")) == 3
|
|
241
|
+
assert [call[1:] for call in client.calls] == [
|
|
242
|
+
(["k"], ["1", "2"]),
|
|
243
|
+
([b"a", "b"], ["2.5"]),
|
|
244
|
+
([], ["1", memoryview(b"x")]),
|
|
245
|
+
]
|
|
246
|
+
assert client.calls[0][0] == module.RATE_LIMIT
|
|
247
|
+
for script, args in ((module.echo, (None, "")), (module.touch_all, ("ab", 1))):
|
|
248
|
+
try:
|
|
249
|
+
script(client, *args)
|
|
250
|
+
except TypeError:
|
|
251
|
+
pass
|
|
252
|
+
else:
|
|
253
|
+
raise AssertionError("accepted a value Redis cannot represent")
|
|
254
|
+
print("ok")
|
|
255
|
+
"""
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
class TestPython39:
|
|
259
|
+
"""A generated module imports and runs on Python 3.9.
|
|
260
|
+
|
|
261
|
+
This package needs 3.10, but a library vendoring the generated code may
|
|
262
|
+
still support 3.9, and the module is all of this package it ships.
|
|
263
|
+
"""
|
|
264
|
+
|
|
265
|
+
@pytest.fixture
|
|
266
|
+
def widened(self, tmp_path):
|
|
267
|
+
out = tmp_path / "_lua.py"
|
|
268
|
+
codegen.generate(module_of(opaque=opaque, maybe=maybe), out)
|
|
269
|
+
return out
|
|
270
|
+
|
|
271
|
+
@pytest.mark.parametrize("which", ["checked_in", "widened"])
|
|
272
|
+
def test_parses_as_python_39(self, request, which):
|
|
273
|
+
path = GENERATED if which == "checked_in" else request.getfixturevalue("widened")
|
|
274
|
+
ast.parse(path.read_text(), feature_version=(3, 9))
|
|
275
|
+
|
|
276
|
+
@pytest.mark.parametrize("which", ["checked_in", "widened"])
|
|
277
|
+
def test_evaluates_no_union_operator(self, request, which):
|
|
278
|
+
# `X | Y` parses on 3.9 but fails there when evaluated. Annotations
|
|
279
|
+
# never are, under `from __future__ import annotations`.
|
|
280
|
+
path = GENERATED if which == "checked_in" else request.getfixturevalue("widened")
|
|
281
|
+
tree = ast.parse(path.read_text())
|
|
282
|
+
annotations = {
|
|
283
|
+
id(inner)
|
|
284
|
+
for node in ast.walk(tree)
|
|
285
|
+
for annotation in (getattr(node, "annotation", None), getattr(node, "returns", None))
|
|
286
|
+
if annotation is not None
|
|
287
|
+
for inner in ast.walk(annotation)
|
|
288
|
+
}
|
|
289
|
+
evaluated = [
|
|
290
|
+
ast.unparse(node)
|
|
291
|
+
for node in ast.walk(tree)
|
|
292
|
+
if isinstance(node, ast.BinOp)
|
|
293
|
+
and isinstance(node.op, ast.BitOr)
|
|
294
|
+
and id(node) not in annotations
|
|
295
|
+
]
|
|
296
|
+
|
|
297
|
+
assert "from __future__ import annotations" in path.read_text()
|
|
298
|
+
assert evaluated == []
|
|
299
|
+
|
|
300
|
+
def test_runs_on_python_39(self, widened):
|
|
301
|
+
interpreter = python39()
|
|
302
|
+
if interpreter is None:
|
|
303
|
+
pytest.skip("no Python 3.9 available through uv")
|
|
304
|
+
|
|
305
|
+
def run(code: str, path: Path) -> subprocess.CompletedProcess[str]:
|
|
306
|
+
return subprocess.run(
|
|
307
|
+
[interpreter, "-I", "-c", code, str(path)],
|
|
308
|
+
capture_output=True,
|
|
309
|
+
text=True,
|
|
310
|
+
check=False,
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
imported = run(LOAD, widened)
|
|
314
|
+
assert imported.returncode == 0, imported.stderr
|
|
315
|
+
called = run(LOAD + CALL, GENERATED)
|
|
316
|
+
assert called.stdout == "ok\n", called.stderr
|
|
317
|
+
|
|
318
|
+
|
|
202
319
|
class TestPythonModule:
|
|
203
320
|
def test_holds_each_script_exactly(self):
|
|
204
321
|
assert codegen_scripts.rate_limit.lua == generated_scripts.RATE_LIMIT
|
|
@@ -33,6 +33,26 @@ def test_numeric_annotations_get_tonumber(body: Body) -> None:
|
|
|
33
33
|
assert "local text = ARGV[3]" in emitted
|
|
34
34
|
|
|
35
35
|
|
|
36
|
+
def test_a_number_only_handed_to_redis_stays_text(body: Body) -> None:
|
|
37
|
+
@script
|
|
38
|
+
def s(k: Key, ttl: int, score: float, n: int, limit: int, step: int) -> int:
|
|
39
|
+
redis.expire(k, ttl)
|
|
40
|
+
redis.call("ZADD", k, score, "member")
|
|
41
|
+
redis.incrby(k, n)
|
|
42
|
+
if redis.incrby(k, limit) > limit:
|
|
43
|
+
return 0
|
|
44
|
+
step = step + 1
|
|
45
|
+
return redis.incrby(k, step)
|
|
46
|
+
|
|
47
|
+
emitted = body(s)
|
|
48
|
+
assert "local ttl = ARGV[1]" in emitted
|
|
49
|
+
assert "local score = ARGV[2]" in emitted
|
|
50
|
+
assert "local n = ARGV[3]" in emitted
|
|
51
|
+
# Compared, or assigned to: a number in the body, so converted as before.
|
|
52
|
+
assert "local limit = tonumber(ARGV[4])" in emitted
|
|
53
|
+
assert "local step = tonumber(ARGV[5])" in emitted
|
|
54
|
+
|
|
55
|
+
|
|
36
56
|
def test_full_script_emission(body: Body) -> None:
|
|
37
57
|
@script
|
|
38
58
|
def rate_limit(key: Key, limit: int, ttl: int) -> int:
|
|
@@ -46,7 +66,7 @@ def test_full_script_emission(body: Body) -> None:
|
|
|
46
66
|
assert body(rate_limit) == (
|
|
47
67
|
"local key = KEYS[1]\n"
|
|
48
68
|
"local limit = tonumber(ARGV[1])\n"
|
|
49
|
-
"local ttl =
|
|
69
|
+
"local ttl = ARGV[2]\n"
|
|
50
70
|
"local current = redis.call('INCR', key)\n"
|
|
51
71
|
"if current == 1 then\n"
|
|
52
72
|
" redis.call('EXPIRE', key, ttl)\n"
|
|
@@ -172,3 +172,17 @@ class TestAddition:
|
|
|
172
172
|
client.set("n", "5")
|
|
173
173
|
assert "__add" not in bump.lua
|
|
174
174
|
assert bump(client, k="n") == 11
|
|
175
|
+
|
|
176
|
+
def test_the_least_or_greatest_of_numbers_is_a_number(self, client: Any) -> None:
|
|
177
|
+
@script
|
|
178
|
+
def refill(key: Key, capacity: float, rate: float, now: float) -> int:
|
|
179
|
+
tokens = float(redis.get(key))
|
|
180
|
+
delta = max(0, now)
|
|
181
|
+
tokens = min(capacity, tokens + delta * rate)
|
|
182
|
+
return tokens
|
|
183
|
+
|
|
184
|
+
client.set("bucket", "2")
|
|
185
|
+
assert "__add" not in refill.lua
|
|
186
|
+
assert "tokens + delta * rate" in refill.lua
|
|
187
|
+
assert refill(client, key="bucket", capacity=10, rate=2, now=3) == 8
|
|
188
|
+
assert refill(client, key="bucket", capacity=5, rate=2, now=3) == 5
|
|
@@ -383,3 +383,32 @@ class TestDefiniteAssignment:
|
|
|
383
383
|
if n > 0:
|
|
384
384
|
head = 1
|
|
385
385
|
return head
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
class TestNumbersHandedToRedis:
|
|
389
|
+
"""A Lua number is a double, which has no exact form past 2^53."""
|
|
390
|
+
|
|
391
|
+
def test_a_large_integer_arrives_exactly(self, client: Any) -> None:
|
|
392
|
+
@script
|
|
393
|
+
def store(k: Key, n: int) -> bytes:
|
|
394
|
+
redis.set(k, n)
|
|
395
|
+
return redis.get(k)
|
|
396
|
+
|
|
397
|
+
assert store(client, k="n", n=2**53 + 1) == b"9007199254740993"
|
|
398
|
+
|
|
399
|
+
def test_a_float_is_not_rewritten_with_seventeen_digits(self, client: Any) -> None:
|
|
400
|
+
@script
|
|
401
|
+
def store(k: Key, x: float) -> bytes:
|
|
402
|
+
redis.set(k, x)
|
|
403
|
+
return redis.get(k)
|
|
404
|
+
|
|
405
|
+
assert store(client, k="f", x=0.1) == b"0.1"
|
|
406
|
+
|
|
407
|
+
def test_arithmetic_still_sees_a_number(self, client: Any) -> None:
|
|
408
|
+
@script
|
|
409
|
+
def twice(k: Key, n: int) -> int:
|
|
410
|
+
redis.set(k, n)
|
|
411
|
+
return n * 2 + 1
|
|
412
|
+
|
|
413
|
+
assert twice(client, k="n", n=20) == 41
|
|
414
|
+
assert client.get("n") == b"20"
|
|
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
|
|
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
|