63 Commits

Author SHA1 Message Date
4c14a2cf65 fixup! WIP
All checks were successful
Run tests / run-tests (push) Successful in 1m21s
2025-12-10 15:42:22 +09:00
90da53c26c fixup! .gitea/workflows: init test pipeline 2025-12-10 15:42:21 +09:00
e9ce51b97b fixup! WIP
All checks were successful
Run tests / run-tests (push) Successful in 1m23s
2025-12-10 15:38:32 +09:00
a4a22e6565 fixup! WIP 2025-12-10 14:25:42 +09:00
bb7d1a2743 fixup! .gitea/workflows: init test pipeline
All checks were successful
Run tests / run-tests (push) Successful in 1m14s
2025-12-10 13:40:29 +09:00
e68d7effcd fixup! .gitea/workflows: init test pipeline
Some checks failed
Run tests / run-tests (push) Has been cancelled
2025-12-10 13:40:15 +09:00
dc668ab113 fixup! .gitea/workflows: init test pipeline
All checks were successful
Run tests / run-tests (push) Successful in 1m6s
2025-12-10 13:37:02 +09:00
fa7ad3a258 fixup! WIP
All checks were successful
Run tests / run-tests (push) Successful in 1m30s
2025-12-10 13:32:54 +09:00
7f4a980eef fixup! WIP
All checks were successful
Run tests / run-tests (push) Successful in 1m25s
2025-12-10 11:39:30 +09:00
2207001136 fixup! WIP
All checks were successful
Run tests / run-tests (push) Successful in 43s
2025-12-09 21:30:19 +09:00
fead6257c7 fixup! WIP 2025-12-09 21:30:15 +09:00
2a9ace4263 fixup! .gitea/workflows: init test pipeline
All checks were successful
Run tests / run-tests (push) Successful in 1m8s
2025-12-09 20:39:21 +09:00
60fa6529ee fixup! WIP 2025-12-09 20:39:01 +09:00
2e66a9a4b0 fixup! WIP
All checks were successful
Run tests / run-tests (push) Successful in 41s
2025-12-09 18:53:14 +09:00
a087d3bede fixup! WIP
All checks were successful
Run tests / run-tests (push) Successful in 41s
2025-12-09 18:43:42 +09:00
45bb31aba0 fixup! WIP
All checks were successful
Run tests / run-tests (push) Successful in 45s
2025-12-09 18:32:04 +09:00
f15c748558 fixup! WIP
Some checks failed
Run tests / run-tests (push) Has been cancelled
2025-12-09 18:31:10 +09:00
d220342d56 fixup! WIP
All checks were successful
Run tests / run-tests (push) Successful in 46s
2025-12-09 17:44:18 +09:00
108e17edb8 fixup! WIP: docs/economy 2025-12-09 17:40:41 +09:00
12028cee22 fixup! WIP: docs/economy
All checks were successful
Run tests / run-tests (push) Successful in 46s
2025-12-09 17:03:47 +09:00
1515eb6dff fixup! WIP
All checks were successful
Run tests / run-tests (push) Successful in 44s
2025-12-09 17:02:28 +09:00
7199cbf34a WIP: docs/economy 2025-12-09 17:02:22 +09:00
722f0cae93 fixup! WIP 2025-12-09 15:51:54 +09:00
16be0f0fbe fixup! WIP 2025-12-09 15:47:14 +09:00
cec91d923c fixup! WIP 2025-12-09 15:30:16 +09:00
0504cc1a1e fixup! WIP
All checks were successful
Run tests / run-tests (push) Successful in 46s
2025-12-09 15:17:52 +09:00
e7453d0fdd fixup! WIP
All checks were successful
Run tests / run-tests (push) Successful in 1m25s
2025-12-09 14:43:45 +09:00
c6ecb6fae9 fixup! WIP 2025-12-09 13:25:34 +09:00
aaa5a6c556 fixup! WIP
All checks were successful
Run tests / run-tests (push) Successful in 46s
2025-12-09 13:00:16 +09:00
6a83a41f28 fixup! WIP
All checks were successful
Run tests / run-tests (push) Successful in 46s
2025-12-09 12:55:24 +09:00
aa4e8dbee5 fixup! WIP
All checks were successful
Run tests / run-tests (push) Successful in 41s
2025-12-09 12:49:38 +09:00
f39e649b3d fixup! WIP
All checks were successful
Run tests / run-tests (push) Successful in 41s
2025-12-09 11:56:06 +09:00
0a2fc799dd fixup! WIP
All checks were successful
Run tests / run-tests (push) Successful in 1m37s
2025-12-09 04:25:05 +09:00
7d498f9bf1 fixup! WIP
All checks were successful
Run tests / run-tests (push) Successful in 40s
2025-12-08 21:55:25 +09:00
f1b15357f9 fixup! WIP
All checks were successful
Run tests / run-tests (push) Successful in 41s
2025-12-08 21:43:27 +09:00
de896901bb models/Base: add comment about __repr__ impl 2025-12-08 21:43:26 +09:00
15d1763405 fixup! WIP 2025-12-08 21:43:23 +09:00
683981d9dc fixup! .gitea/workflows: init test pipeline
All checks were successful
Run tests / run-tests (push) Successful in 40s
2025-12-08 21:30:07 +09:00
4289d63ac9 fixup! .gitea/workflows: init test pipeline
All checks were successful
Run tests / run-tests (push) Successful in 45s
2025-12-08 21:28:01 +09:00
ce3e65357b fixup! .gitea/workflows: init test pipeline
All checks were successful
Run tests / run-tests (push) Successful in 54s
2025-12-08 21:16:04 +09:00
928ab2a98a fixup! WIP
All checks were successful
Run tests / run-tests (push) Successful in 38s
2025-12-08 21:13:49 +09:00
0b59d469dd fixup! WIP
All checks were successful
Run tests / run-tests (push) Successful in 56s
2025-12-08 21:04:49 +09:00
24c5a9af38 fixup! .gitea/workflows: init test pipeline
All checks were successful
Run tests / run-tests (push) Successful in 53s
2025-12-08 20:33:03 +09:00
21ccf78401 fixup! .gitea/workflows: init test pipeline
All checks were successful
Run tests / run-tests (push) Successful in 1m8s
2025-12-08 20:29:10 +09:00
d5b481d97a fixup! .gitea/workflows: init test pipeline
All checks were successful
Run tests / run-tests (push) Successful in 35s
2025-12-08 20:27:32 +09:00
cac1b5be20 fixup! .gitea/workflows: init test pipeline
Some checks failed
Run tests / run-tests (push) Failing after 24s
2025-12-08 20:24:47 +09:00
ad1fcfe98d fixup! .gitea/workflows: init test pipeline
Some checks failed
Run tests / run-tests (push) Failing after 22s
2025-12-08 20:20:35 +09:00
cc7b40ab7e fixup! .gitea/workflows: init test pipeline
Some checks failed
Run tests / run-tests (push) Failing after 21s
2025-12-08 20:19:09 +09:00
d35ffd04cc fixup! .gitea/workflows: init test pipeline
Some checks failed
Run tests / run-tests (push) Failing after 20s
2025-12-08 20:17:53 +09:00
d39f1f8a92 pyproject.toml: psycopg2 -> psycopg2-binary
Some checks failed
Run tests / run-tests (push) Failing after 24s
2025-12-08 20:07:37 +09:00
0e3bed9bf5 uv.lock: update 2025-12-08 20:07:37 +09:00
3a1fc58a68 .gitea/workflows: init test pipeline 2025-12-08 20:07:37 +09:00
1ec7c79378 fixup! WIP 2025-12-08 19:50:22 +09:00
bc43d4948c README: add overview of project structure 2025-12-08 19:45:37 +09:00
7e5345c7fb fixup! WIP 2025-12-08 19:45:22 +09:00
50867db928 fixup! WIP 2025-12-08 18:26:49 +09:00
5252cb611f fixup! WIP 2025-12-08 18:26:49 +09:00
5f510ee5d8 fixup! WIP 2025-12-08 18:26:48 +09:00
f8829a6c7b fixup! WIP 2025-12-08 18:26:48 +09:00
885e989659 fixup! WIP 2025-12-08 18:26:48 +09:00
5c0b2b5229 WIP 2025-12-08 18:26:48 +09:00
9f2d8229fd .gitignore: add pytest-cov data 2025-12-08 18:26:47 +09:00
8807d7278a {nix,pyproject.toml}: add pytest, pytest-cov 2025-12-08 18:26:46 +09:00
83 changed files with 984 additions and 4617 deletions

View File

@@ -1,71 +0,0 @@
name: Run benchmarks
on:
workflow_dispatch:
# TODO: make this only workflow_dispatch when merged into main
push:
jobs:
run-tests:
runs-on: debian-latest
steps:
- uses: actions/checkout@v6
- name: Install uv
uses: astral-sh/setup-uv@v7
- name: Install dependencies
run: uv sync --locked --group test
- name: Run benchmarks
continue-on-error: true
run: |
set -euo pipefail
set -x
PYTEST_ARGS=(
-vv
--benchmark-only
-k test_benchmark
)
uv run -- pytest "${PYTEST_ARGS[@]}"
- name: Upload benchmark JSON report
uses: https://git.pvv.ntnu.no/Projects/rsync-action@v2
with:
source: ./benchmark/*/*.json
quote-source: false
target: ${{ gitea.ref_name }}/benchmark/${{ github.run_id }}/benchmark.json
username: gitea-web
ssh-key: ${{ secrets.WEB_SYNC_SSH_KEY }}
host: pages.pvv.ntnu.no
known-hosts: "pages.pvv.ntnu.no ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH2QjfFB+city1SYqltkVqWACfo1j37k+oQQfj13mtgg"
- name: Upload histograms
uses: https://git.pvv.ntnu.no/Projects/rsync-action@v2
with:
source: ./benchmark/*.svg
quote-source: false
target: ${{ gitea.ref_name }}/benchmark/${{ github.run_id }}/
username: gitea-web
ssh-key: ${{ secrets.WEB_SYNC_SSH_KEY }}
host: pages.pvv.ntnu.no
known-hosts: "pages.pvv.ntnu.no ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH2QjfFB+city1SYqltkVqWACfo1j37k+oQQfj13mtgg"
# NOTE: $GITHUB_STEP_SUMMARY when...
- name: Run information
run: |
echo "Benchmark run ID: ${{ github.run_id }}"
echo "Benchmark JSON: https://pages.pvv.ntnu.no/${{ gitea.repository }}/${{ gitea.ref_name }}/benchmark/${{ github.run_id }}/benchmark.json"
echo "Histograms: https://pages.pvv.ntnu.no/${{ gitea.repository }}/${{ gitea.ref_name }}/benchmark/${{ github.run_id }}/histogram-product_owners.svg"
echo " https://pages.pvv.ntnu.no/${{ gitea.repository }}/${{ gitea.ref_name }}/benchmark/${{ github.run_id }}/histogram-product_price.svg"
echo " https://pages.pvv.ntnu.no/${{ gitea.repository }}/${{ gitea.ref_name }}/benchmark/${{ github.run_id }}/histogram-product_stock.svg"
echo " https://pages.pvv.ntnu.no/${{ gitea.repository }}/${{ gitea.ref_name }}/benchmark/${{ github.run_id }}/histogram-transaction_log.svg"
echo " https://pages.pvv.ntnu.no/${{ gitea.repository }}/${{ gitea.ref_name }}/benchmark/${{ github.run_id }}/histogram-user_balance.svg"
- name: Check failure
if: failure()
run: |
echo "Tests failed"
exit 1

View File

@@ -16,57 +16,66 @@ jobs:
run-tests:
runs-on: debian-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v6
- name: Install uv
uses: astral-sh/setup-uv@v7
- name: Install uv
uses: astral-sh/setup-uv@v7
- name: Install dependencies
run: uv sync --locked --group test
- name: Install dependencies
run: uv sync --locked --group test
- name: Run tests
continue-on-error: true
run: |
set -euo pipefail
set -x
- name: Run tests
continue-on-error: true
run: |
set -euo pipefail
set -x
PYTEST_ARGS=(
-vv
PYTEST_ARGS=(
-vv
--cov=dibbler.lib
--cov=dibbler.models
--cov=dibbler.queries
--cov-report=html
--cov-branch
--self-contained-html
--html=./test-report/index.html
)
if [ "$DEBUG_SQL" == "true" ]; then
PYTEST_ARGS+=(
--debug-sql
)
fi
if [ "$DEBUG_SQL" == "true" ]; then
PYTEST_ARGS+=(
--debug-sql
)
fi
uv run -- pytest "${PYTEST_ARGS[@]}"
uv run -- pytest "${PYTEST_ARGS[@]}"
- name: Generate badge
run: uv run -- coverage-badge -o htmlcov/badge.svg
- name: Generate badge
run: uv run -- coverage-badge -o htmlcov/badge.svg
- name: Upload test report
uses: https://git.pvv.ntnu.no/Projects/rsync-action@v1
with:
source: ./test-report/
target: ${{ gitea.ref_name }}/test-report/
username: gitea-web
ssh-key: ${{ secrets.WEB_SYNC_SSH_KEY }}
host: pages.pvv.ntnu.no
known-hosts: "pages.pvv.ntnu.no ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH2QjfFB+city1SYqltkVqWACfo1j37k+oQQfj13mtgg"
- name: Upload test report
uses: https://git.pvv.ntnu.no/Projects/rsync-action@v1
with:
source: ./test-report/
target: ${{ gitea.ref_name }}/test-report/
username: gitea-web
ssh-key: ${{ secrets.WEB_SYNC_SSH_KEY }}
host: pages.pvv.ntnu.no
known-hosts: "pages.pvv.ntnu.no ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH2QjfFB+city1SYqltkVqWACfo1j37k+oQQfj13mtgg"
- name: Upload coverage report
uses: https://git.pvv.ntnu.no/Projects/rsync-action@v1
with:
source: ./htmlcov/
target: ${{ gitea.ref_name }}/coverage/
username: gitea-web
ssh-key: ${{ secrets.WEB_SYNC_SSH_KEY }}
host: pages.pvv.ntnu.no
known-hosts: "pages.pvv.ntnu.no ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH2QjfFB+city1SYqltkVqWACfo1j37k+oQQfj13mtgg"
- name: Upload coverage report
uses: https://git.pvv.ntnu.no/Projects/rsync-action@v1
with:
source: ./htmlcov/
target: ${{ gitea.ref_name }}/coverage/
username: gitea-web
ssh-key: ${{ secrets.WEB_SYNC_SSH_KEY }}
host: pages.pvv.ntnu.no
known-hosts: "pages.pvv.ntnu.no ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH2QjfFB+city1SYqltkVqWACfo1j37k+oQQfj13mtgg"
- name: Check failure
if: failure()
run: |
echo "Tests failed"
exit 1
- name: Check failure
if: failure()
run: |
echo "Tests failed"
exit 1

4
.gitignore vendored
View File

@@ -8,10 +8,6 @@ test.db
.ruff_cache
*.qcow2
.coverage
.coverage.*
htmlcov
test-report
/benchmark

View File

@@ -24,7 +24,6 @@ Deretter kan du kjøre programmet med
```console
python -m dibbler -c example-config.ini create-db
python -m dibbler -c example-config.ini seed-data
python -m dibbler -c example-config.ini loop
```
@@ -62,21 +61,25 @@ Her ligger enhetstester for prosjektet. Testene bruker `pytest` som testløper.
## Nix
Du kan enklest komme i gang med nix-utvikling ved å kjøre VM-en:
### Bygge nytt image
```console
nix run .#vm
For å bygge et image trenger du en builder som takler å bygge for arkitekturen du skal lage et image for.
# Eller hvis du trenger tilgang til terminalen i VM-en også:
nix run .#vm-non-kiosk
```
(Eller be til gudene om at cross compile funker)
Du kan også bygge pakken manuelt, eller kjøre den direkte:
Flaket exposer en modul som autologger inn med en bruker som automatisk kjører dibbler, og setter opp et minimalistisk miljø.
```console
nix build .#dibbler
Før du bygger imaget burde du kopiere og endre `example-config.ini` lokalt til å inneholde instillingene dine. **NB: Denne kommer til å ligge i nix storen, ikke si noe her som du ikke vil at moren din skal høre.**
nix run .# -- --config example-config.ini create-db
nix run .# -- --config example-config.ini seed-data
nix run .# -- --config example-config.ini loop
```
Du kan også endre hvilken config-fil som blir brukt direkte i pakken eller i modulen.
Se eksempelet for hvordan skrot er satt opp i `flake.nix` og `nix/skrott.nix`
### Bygge image for skrot
Skrot har et image definert i flake.nix:
1. endre `example-config.ini`
2. `nix build .#images.skrot`
3. ???
4. non-profit

View File

@@ -1,25 +1,6 @@
# This module is supposed to act as a singleton and be filled
# with config variables by cli.py
from pathlib import Path
import os
import configparser
config = configparser.ConfigParser()
DEFAULT_CONFIG_PATH = Path('/etc/dibbler/dibbler.conf')
def default_config_path_submissive_and_readable() -> bool:
return DEFAULT_CONFIG_PATH.is_file() and any(
[
(
DEFAULT_CONFIG_PATH.stat().st_mode & 0o400
and DEFAULT_CONFIG_PATH.stat().st_uid == os.getuid()
),
(
DEFAULT_CONFIG_PATH.stat().st_mode & 0o040
and DEFAULT_CONFIG_PATH.stat().st_gid == os.getgid()
),
(DEFAULT_CONFIG_PATH.stat().st_mode & 0o004),
]
)

View File

@@ -0,0 +1,22 @@
from typing import TypeVar
from sqlalchemy import BindParameter, literal
T = TypeVar("T")
def const(value: T) -> BindParameter[T]:
"""
Create a constant SQL literal bind parameter.
This is useful to avoid too many `?` bind parameters in SQL queries,
when the input value is known to be safe.
"""
return literal(value, literal_execute=True)
CONST_ZERO: BindParameter[int] = const(0)
CONST_ONE: BindParameter[int] = const(1)
CONST_TRUE: BindParameter[bool] = const(True)
CONST_FALSE: BindParameter[bool] = const(False)
CONST_NONE: BindParameter[None] = const(None)

View File

@@ -1,4 +1,3 @@
from dibbler.lib.render_tree import render_tree
from dibbler.models import Transaction, TransactionType
from dibbler.models.Transaction import EXPECTED_FIELDS
@@ -11,19 +10,23 @@ def render_transaction_log(transaction_log: list[Transaction]) -> str:
aggregated_log = _aggregate_joint_transactions(transaction_log)
lines = []
for transaction in aggregated_log:
for i, transaction in enumerate(aggregated_log):
if isinstance(transaction, list):
inner_lines = []
lines.append(_render_transaction(transaction[0]))
for sub_transaction in transaction[1:]:
line = _render_transaction(sub_transaction)
is_last = i == len(aggregated_log) - 1
lines.append(_render_transaction(transaction[0], is_last))
for j, sub_transaction in enumerate(transaction[1:]):
is_last_inner = j == len(transaction) - 2
line = _render_transaction(sub_transaction, is_last_inner)
inner_lines.append(line)
lines.append(inner_lines)
indented_inner_lines = _indent_lines(inner_lines, is_last=is_last)
lines.extend(indented_inner_lines)
else:
line = _render_transaction(transaction)
is_last = i == len(aggregated_log) - 1
line = _render_transaction(transaction, is_last)
lines.append(line)
return render_tree(lines)
return "\n".join(lines)
def _aggregate_joint_transactions(
@@ -58,7 +61,17 @@ def _aggregate_joint_transactions(
return aggregated
def _render_transaction(transaction: Transaction) -> str:
def _indent_lines(lines: list[str], is_last: bool = False) -> list[str]:
indented_lines = []
for line in lines:
if is_last:
indented_lines.append(" " + line)
else:
indented_lines.append("" + line)
return indented_lines
def _render_transaction(transaction: Transaction, is_last: bool) -> str:
match transaction.type_:
case TransactionType.ADD_PRODUCT:
line = f"ADD_PRODUCT({transaction.id}, {transaction.user.name}"
@@ -112,4 +125,5 @@ def _render_transaction(transaction: Transaction) -> str:
line = (
f"UNKNOWN[{transaction.type_}](id={transaction.id}, user_id={transaction.user_id})"
)
return line
return "└─ " + line if is_last else "├─ " + line

View File

@@ -1,115 +0,0 @@
_TREE_CHARS = {
"normal": {
"vertical": "",
"branch": "├─ ",
"last": "└─ ",
"empty": " ",
},
"ascii": {
"vertical": "| ",
"branch": "|-- ",
"last": "`-- ",
"empty": " ",
},
}
assert set(_TREE_CHARS["normal"].keys()) == set(_TREE_CHARS["ascii"].keys())
assert all(len(v) == 3 for v in _TREE_CHARS["normal"].values())
assert all(len(v) == 4 for v in _TREE_CHARS["ascii"].values())
def render_tree(
tree: list[str | list],
ascii_only: bool = False,
) -> str:
"""
Render a tree structure as a string.
Each item in the `tree` list can be either a string (a leaf node)
or another list (a subtree).
When `ascii_only` is `True`, only ASCII characters are used for drawing the tree.
Example:
```python
tree = [
"root",
[
"child1",
[
"grandchild1",
"grandchild2",
],
"child2",
],
"root2",
]
print(render_tree(tree, ascii_only=False))
```
Output:
```
├─ root
│ ├─ child1
│ │ ├─ grandchild1
│ │ └─ grandchild2
│ └─ child2
└─ root2
```
Example with ASCII only:
```python
print(render_tree(tree, ascii_only=True))
```
Output:
```
|-- root
| |-- child1
| | |-- grandchild1
| | `-- grandchild2
| `-- child2
`-- root2
```
"""
result: list[str] = []
for index, item in enumerate(tree):
is_last = index == len(tree) - 1
item_lines = _render_tree_line(item, is_last, ascii_only)
result.extend(item_lines)
return "\n".join(result)
def _render_tree_line(
item: str | list,
is_last: bool,
ascii_only: bool,
prefix: str = "",
) -> list[str]:
chars = _TREE_CHARS["ascii"] if ascii_only else _TREE_CHARS["normal"]
lines: list[str] = []
if isinstance(item, str):
line_prefix = chars["last"] if is_last else chars["branch"]
item_lines = item.splitlines()
for line_index, line in enumerate(item_lines):
if line_index == 0:
lines.append(f"{prefix}{line_prefix}{line}")
else:
lines.append(f"{prefix}{chars['vertical']}{line}")
elif isinstance(item, list):
new_prefix = prefix + (chars["empty"] if is_last else chars["vertical"])
for sub_index, sub_item in enumerate(item):
sub_is_last = sub_index == len(item) - 1
sub_lines = _render_tree_line(sub_item, sub_is_last, ascii_only, new_prefix)
lines.extend(sub_lines)
else:
raise ValueError("Item must be either a string or a list.")
return lines

View File

@@ -1,8 +1,7 @@
import argparse
import sys
from pathlib import Path
from dibbler.conf import DEFAULT_CONFIG_PATH, config, default_config_path_submissive_and_readable
from dibbler.conf import config
parser = argparse.ArgumentParser()
@@ -29,13 +28,7 @@ subparsers.add_parser("transaction-log", help="Print transaction log")
def main():
args = parser.parse_args()
if args.config is not None:
config.read(args.config)
elif default_config_path_submissive_and_readable():
config.read(DEFAULT_CONFIG_PATH)
else:
print("Could not read config file, it was neither provided nor readable in default location", file=sys.stderr)
config.read(args.config)
if args.subcommand == "loop":
import dibbler.subcommands.loop as loop

View File

@@ -17,19 +17,16 @@ def _pascal_case_to_snake_case(name: str) -> str:
class Base(DeclarativeBase):
metadata = MetaData(
naming_convention={
"ix": "ix__%(table_name)s__%(column_0_label)s",
"uq": "uq__%(table_name)s__%(column_0_name)s",
"ck": "ck__%(table_name)s__%(constraint_name)s",
"fk": "fk__%(table_name)s__%(column_0_name)s_%(referred_table_name)s",
"pk": "pk__%(table_name)s",
"ix": "ix_%(table_name)s_%(column_0_label)s",
"uq": "uq_%(table_name)s_%(column_0_name)s",
"ck": "ck_%(table_name)s_%(constraint_name)s",
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
"pk": "pk_%(table_name)s",
}
)
@declared_attr.directive
def __tablename__(cls) -> str:
if hasattr(cls, "__table_name__"):
assert isinstance(cls.__table_name__, str)
return cls.__table_name__
return _pascal_case_to_snake_case(cls.__name__)
# NOTE: This is the default implementation of __repr__ for all tables,

View File

@@ -1,26 +0,0 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from sqlalchemy import Integer, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from dibbler.models import Base
if TYPE_CHECKING:
from dibbler.models import Transaction
class LastCacheTransaction(Base):
"""Tracks the last transaction that affected various caches."""
id: Mapped[int] = mapped_column(Integer, primary_key=True)
"""Internal database ID"""
transaction_id: Mapped[int | None] = mapped_column(ForeignKey("trx.id"), index=True)
"""The ID of the last transaction that affected the cache(s)."""
transaction: Mapped[Transaction | None] = relationship(
lazy="joined",
foreign_keys=[transaction_id],
)
"""The last transaction that affected the cache(s)."""

View File

@@ -19,7 +19,6 @@ class Product(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True)
"""Internal database ID"""
# TODO: add more validation for barcode
bar_code: Mapped[str] = mapped_column(String(13), unique=True)
"""
The bar code of the product.

View File

@@ -1,30 +1,16 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from datetime import datetime
from sqlalchemy import Integer, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy import Integer, DateTime
from sqlalchemy.orm import Mapped, mapped_column
from dibbler.models import Base
if TYPE_CHECKING:
from dibbler.models import LastCacheTransaction, Product
class ProductCache(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True)
"""Internal database ID"""
product_id: Mapped[int] = mapped_column(ForeignKey('product.id'))
product: Mapped[Product] = relationship(
lazy="joined",
foreign_keys=[product_id],
)
product_id: Mapped[int] = mapped_column(Integer, primary_key=True)
price: Mapped[int] = mapped_column(Integer)
stock: Mapped[int] = mapped_column(Integer)
price_timestamp: Mapped[datetime] = mapped_column(DateTime)
last_cache_transaction_id: Mapped[int | None] = mapped_column(ForeignKey("last_cache_transaction.id"), nullable=True)
last_cache_transaction: Mapped[LastCacheTransaction | None] = relationship(
lazy="joined",
foreign_keys=[last_cache_transaction_id],
)
stock: Mapped[int] = mapped_column(Integer)
stock_timestamp: Mapped[datetime] = mapped_column(DateTime)

View File

@@ -33,10 +33,11 @@ if TYPE_CHECKING:
from .Product import Product
from .User import User
# TODO: rename to *_PERCENT
# NOTE: these only matter when there are no adjustments made in the database.
DEFAULT_INTEREST_RATE_PERCENT = 100
DEFAULT_INTEREST_RATE_PERCENTAGE = 100
DEFAULT_PENALTY_THRESHOLD = -100
DEFAULT_PENALTY_MULTIPLIER_PERCENT = 200
DEFAULT_PENALTY_MULTIPLIER_PERCENTAGE = 200
_DYNAMIC_FIELDS: set[str] = {
"amount",
@@ -87,7 +88,6 @@ def _transaction_type_field_constraints(
class Transaction(Base):
__tablename__ = "trx"
__table_args__ = (
*[
_transaction_type_field_constraints(transaction_type, expected_fields)
@@ -131,15 +131,12 @@ class Transaction(Base):
),
name="trx_joint_transaction_id_not_self",
),
# Speed up product stock calculation
Index("ix__transaction__product_id_type_time", "product_id", "type", "time"),
# Speed up product count calculation
Index("product_user_time", "product_id", "user_id", "time"),
# Speed up product owner calculation
Index("ix__transaction__user_id_product_time", "user_id", "product_id", "time"),
Index("user_product_time", "user_id", "product_id", "time"),
# Speed up user transaction list / credit calculation
Index("ix__transaction__user_id_time", "user_id", "time"),
Index("user_time", "user_id", "time"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
@@ -149,7 +146,7 @@ class Transaction(Base):
Not used for anything else than identifying the transaction in the database.
"""
time: Mapped[datetime] = mapped_column(DateTime, index=True)
time: Mapped[datetime] = mapped_column(DateTime)
"""
The time when the transaction took place.
@@ -165,7 +162,7 @@ class Transaction(Base):
This is not used for any calculations, but can be useful for debugging.
"""
type_: Mapped[TransactionType] = mapped_column(TransactionTypeSQL, name="type", index=True)
type_: Mapped[TransactionType] = mapped_column(TransactionTypeSQL, name="type")
"""
Which type of transaction this is.
@@ -192,7 +189,7 @@ class Transaction(Base):
that the user paid in the store would be stored in the `amount` field.
"""
user_id: Mapped[int] = mapped_column(ForeignKey("user.id"), index=True)
user_id: Mapped[int] = mapped_column(ForeignKey("user.id"))
"""The user who performs the transaction. See `user` for more details."""
user: Mapped[User] = relationship(
lazy="joined",
@@ -210,10 +207,7 @@ class Transaction(Base):
In the case of `JOINT` transactions, this is the user who initiated the joint transaction.
"""
joint_transaction_id: Mapped[int | None] = mapped_column(
ForeignKey("trx.id"),
index=True,
)
joint_transaction_id: Mapped[int | None] = mapped_column(ForeignKey("transaction.id"))
"""
An optional ID to group multiple transactions together as part of a joint transaction.
@@ -229,7 +223,7 @@ class Transaction(Base):
"""
# Receiving user when moving credit from one user to another
transfer_user_id: Mapped[int | None] = mapped_column(ForeignKey("user.id"), index=True)
transfer_user_id: Mapped[int | None] = mapped_column(ForeignKey("user.id"))
"""The user who receives money in a `TRANSFER` transaction."""
transfer_user: Mapped[User | None] = relationship(
lazy="joined",
@@ -238,7 +232,7 @@ class Transaction(Base):
"""The user who receives money in a `TRANSFER` transaction."""
# The product that is either being added or bought
product_id: Mapped[int | None] = mapped_column(ForeignKey("product.id"), index=True)
product_id: Mapped[int | None] = mapped_column(ForeignKey("product.id"))
"""The product being added or bought."""
product: Mapped[Product | None] = relationship(lazy="joined")
"""The product being added or bought."""
@@ -336,6 +330,7 @@ class Transaction(Base):
Validates the transaction's fields based on its type.
Raises `ValueError` if the transaction is invalid.
"""
# TODO: do we allow free products?
if self.amount == 0:
raise ValueError("Amount must not be zero.")
@@ -405,11 +400,6 @@ class Transaction(Base):
time: datetime | None = None,
message: str | None = None,
) -> Self:
"""
Convenience constructor for creating an `ADJUST_BALANCE` transaction.
Should NOT be used directly in the application code; use the various queries instead.
"""
return cls(
time=time,
type_=TransactionType.ADJUST_BALANCE,
@@ -426,14 +416,6 @@ class Transaction(Base):
time: datetime | None = None,
message: str | None = None,
) -> Self:
"""
Convenience constructor for creating an `ADJUST_INTEREST` transaction.
Note that the `interest_rate_percent` is absolute, not relative to the previous interest rate.
Should NOT be used directly in the application code; use the various queries instead.
"""
return cls(
time=time,
type_=TransactionType.ADJUST_INTEREST,
@@ -451,14 +433,6 @@ class Transaction(Base):
time: datetime | None = None,
message: str | None = None,
) -> Self:
"""
Convenience constructor for creating an `ADJUST_PENALTY` transaction.
Note that both `penalty_multiplier_percent` and `penalty_threshold` are absolute,
not relative to the previous settings.
Should NOT be used directly in the application code; use the various queries instead.
"""
return cls(
time=time,
type_=TransactionType.ADJUST_PENALTY,
@@ -477,11 +451,6 @@ class Transaction(Base):
time: datetime | None = None,
message: str | None = None,
) -> Self:
"""
Convenience constructor for creating an `ADJUST_STOCK` transaction.
Should NOT be used directly in the application code; use the various queries instead.
"""
return cls(
time=time,
type_=TransactionType.ADJUST_STOCK,
@@ -502,11 +471,6 @@ class Transaction(Base):
time: datetime | None = None,
message: str | None = None,
) -> Self:
"""
Convenience constructor for creating an `ADD_PRODUCT` transaction.
Should NOT be used directly in the application code; use the various queries instead.
"""
return cls(
time=time,
type_=TransactionType.ADD_PRODUCT,
@@ -527,11 +491,6 @@ class Transaction(Base):
time: datetime | None = None,
message: str | None = None,
) -> Self:
"""
Convenience constructor for creating a `BUY_PRODUCT` transaction.
Should NOT be used directly in the application code; use the various queries instead.
"""
return cls(
time=time,
type_=TransactionType.BUY_PRODUCT,
@@ -550,11 +509,6 @@ class Transaction(Base):
time: datetime | None = None,
message: str | None = None,
) -> Self:
"""
Convenience constructor for creating a `JOINT` transaction.
Should NOT be used directly in the application code; use the various queries instead.
"""
return cls(
time=time,
type_=TransactionType.JOINT,
@@ -572,11 +526,6 @@ class Transaction(Base):
time: datetime | None = None,
message: str | None = None,
) -> Self:
"""
Convenience constructor for creating a `JOINT_BUY_PRODUCT` transaction.
Should NOT be used directly in the application code; use the various queries instead.
"""
return cls(
time=time,
type_=TransactionType.JOINT_BUY_PRODUCT,
@@ -594,11 +543,6 @@ class Transaction(Base):
time: datetime | None = None,
message: str | None = None,
) -> Self:
"""
Convenience constructor for creating a `TRANSFER` transaction.
Should NOT be used directly in the application code; use the various queries instead.
"""
return cls(
time=time,
type_=TransactionType.TRANSFER,
@@ -617,11 +561,6 @@ class Transaction(Base):
time: datetime | None = None,
message: str | None = None,
) -> Self:
"""
Convenience constructor for creating a `THROW_PRODUCT` transaction.
Should NOT be used directly in the application code; use the various queries instead.
"""
return cls(
time=time,
type_=TransactionType.THROW_PRODUCT,

View File

@@ -1,30 +1,14 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from datetime import datetime
from sqlalchemy import Integer, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy import Integer, DateTime
from sqlalchemy.orm import Mapped, mapped_column
from dibbler.models import Base
if TYPE_CHECKING:
from dibbler.models import LastCacheTransaction, User
# More like user balance cash money flow, amirite?
class UserCache(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True)
"""internal database id"""
user_id: Mapped[int] = mapped_column(ForeignKey('user.id'))
user: Mapped[User] = relationship(
lazy="joined",
foreign_keys=[user_id],
)
class UserBalanceCache(Base):
user_id: Mapped[int] = mapped_column(Integer, primary_key=True)
balance: Mapped[int] = mapped_column(Integer)
last_cache_transaction_id: Mapped[int | None] = mapped_column(ForeignKey("last_cache_transaction.id"), nullable=True)
last_cache_transaction: Mapped[LastCacheTransaction | None] = relationship(
lazy="joined",
foreign_keys=[last_cache_transaction_id],
)
timestamp: Mapped[datetime] = mapped_column(DateTime)

View File

@@ -1,19 +1,13 @@
__all__ = [
"Base",
"LastCacheTransaction",
"Product",
"ProductCache",
"Transaction",
"TransactionType",
"User",
"UserCache",
]
from .Base import Base
from .LastCacheTransaction import LastCacheTransaction
from .Product import Product
from .ProductCache import ProductCache
from .Transaction import Transaction
from .TransactionType import TransactionType
from .User import User
from .UserCache import UserCache

View File

@@ -1,13 +1,8 @@
__all__ = [
"add_product",
"adjust_balance",
# "add_product",
# "add_user",
"adjust_interest",
"adjust_penalty",
"adjust_stock",
"affected_products",
"affected_users",
"create_product",
"create_user",
"current_interest",
"current_penalty",
"joint_buy_product",
@@ -16,37 +11,27 @@ __all__ = [
"product_price",
"product_price_log",
"product_stock",
# "products_owned_by_user",
"search_product",
"search_user",
"throw_product",
"transaction_log",
"transfer",
"update_cache",
"user_balance",
"user_balance_log",
"user_products",
]
from .add_product import add_product
from .adjust_balance import adjust_balance
# from .add_product import add_product
# from .add_user import add_user
from .adjust_interest import adjust_interest
from .adjust_penalty import adjust_penalty
from .adjust_stock import adjust_stock
from .affected_products import affected_products
from .affected_users import affected_users
from .create_product import create_product
from .create_user import create_user
from .current_interest import current_interest
from .current_penalty import current_penalty
from .joint_buy_product import joint_buy_product
from .product_owners import product_owners, product_owners_log
from .product_price import product_price, product_price_log
from .product_stock import product_stock
# from .products_owned_by_user import products_owned_by_user
from .search_product import search_product
from .search_user import search_user
from .throw_product import throw_product
from .transaction_log import transaction_log
from .transfer import transfer
from .update_cache import update_cache
from .user_balance import user_balance, user_balance_log
from .user_products import user_products

View File

@@ -1,51 +1 @@
from datetime import datetime
from sqlalchemy.orm import Session
from dibbler.models import Product, Transaction, User
def add_product(
sql_session: Session,
user: User,
product: Product,
amount: int,
per_product: int,
product_count: int,
time: datetime | None = None,
message: str | None = None,
) -> Transaction:
if user.id is None:
raise ValueError("User must be persisted in the database.")
if product.id is None:
raise ValueError("Product must be persisted in the database.")
if amount <= 0:
raise ValueError("Amount must be positive.")
if per_product <= 0:
raise ValueError("Per product price must be positive.")
if product_count <= 0:
raise ValueError("Product count must be positive.")
if per_product * product_count < amount:
raise ValueError("Total per product price must be at least equal to amount.")
# TODO: verify time is not behind last transaction's time
transaction = Transaction.add_product(
user_id=user.id,
product_id=product.id,
amount=amount,
per_product=per_product,
product_count=product_count,
time=time,
message=message,
)
sql_session.add(transaction)
sql_session.commit()
return transaction
# TODO: implement me

View File

@@ -0,0 +1 @@
# TODO: implement me

View File

@@ -1,33 +0,0 @@
from datetime import datetime
from sqlalchemy.orm import Session
from dibbler.models import Transaction, User
def adjust_balance(
sql_session: Session,
user: User,
amount: int,
time: datetime | None = None,
message: str | None = None,
) -> Transaction:
if user.id is None:
raise ValueError("User must be persisted in the database.")
if amount == 0:
raise ValueError("Amount must be non-zero.")
# TODO: verify time is not behind last transaction's time
transaction = Transaction.adjust_balance(
user_id=user.id,
amount=amount,
time=time,
message=message,
)
sql_session.add(transaction)
sql_session.commit()
return transaction

View File

@@ -1,5 +1,3 @@
from datetime import datetime
from sqlalchemy.orm import Session
from dibbler.models import Transaction, User
@@ -12,25 +10,19 @@ def adjust_interest(
sql_session: Session,
user: User,
new_interest: int,
time: datetime | None = None,
message: str | None = None,
) -> Transaction:
) -> None:
if new_interest < 0:
raise ValueError("Interest rate cannot be negative")
if user.id is None:
raise ValueError("User must be persisted in the database.")
# TODO: verify time is not behind last transaction's time
transaction = Transaction.adjust_interest(
user_id=user.id,
interest_rate_percent=new_interest,
time=time,
message=message,
)
sql_session.add(transaction)
sql_session.commit()
return transaction

View File

@@ -1,5 +1,3 @@
from datetime import datetime
from sqlalchemy.orm import Session
from dibbler.models import Transaction, User
@@ -14,9 +12,8 @@ def adjust_penalty(
user: User,
new_penalty: int | None = None,
new_penalty_multiplier: int | None = None,
time: datetime | None = None,
message: str | None = None,
) -> Transaction:
) -> None:
if new_penalty is None and new_penalty_multiplier is None:
raise ValueError("At least one of new_penalty or new_penalty_multiplier must be provided")
@@ -33,17 +30,12 @@ def adjust_penalty(
if new_penalty_multiplier is None:
new_penalty_multiplier = existing_penalty_multiplier
# TODO: verify time is not behind last transaction's time
transaction = Transaction.adjust_penalty(
user_id=user.id,
penalty_threshold=new_penalty,
penalty_multiplier_percent=new_penalty_multiplier,
time=time,
message=message,
)
sql_session.add(transaction)
sql_session.commit()
return transaction

View File

@@ -1,40 +0,0 @@
from datetime import datetime
from sqlalchemy.orm import Session
from dibbler.models import Product, Transaction, User
def adjust_stock(
sql_session: Session,
user: User,
product: Product,
product_count: int,
time: datetime | None = None,
message: str | None = None,
) -> Transaction:
if user.id is None:
raise ValueError("User must be persisted in the database.")
if product.id is None:
raise ValueError("Product must be persisted in the database.")
if product_count == 0:
raise ValueError("Product count must be non-zero.")
# TODO: it should not be possible to reduce stock below zero.
#
# TODO: verify time is not behind last transaction's time
transaction = Transaction.adjust_stock(
user_id=user.id,
product_id=product.id,
product_count=product_count,
time=time,
message=message,
)
sql_session.add(transaction)
sql_session.commit()
return transaction

View File

@@ -1,88 +0,0 @@
from datetime import datetime
from sqlalchemy import BindParameter, select
from sqlalchemy.orm import Session
from dibbler.models import Product, Transaction, TransactionType
from dibbler.queries.query_helpers import until_filter, after_filter
def affected_products(
sql_session: Session,
until_time: BindParameter[datetime] | datetime | None = None,
until_transaction: BindParameter[Transaction] | Transaction | None = None,
until_inclusive: bool = True,
after_time: BindParameter[datetime] | datetime | None = None,
after_transaction: Transaction | None = None,
after_inclusive: bool = True,
) -> set[Product]:
"""
Get all products where attributes were affected over a given interval.
"""
if isinstance(until_time, datetime):
until_time = BindParameter("until_time", value=until_time)
if isinstance(until_transaction, Transaction):
if until_transaction.id is None:
raise ValueError("until_transaction must be persisted in the database.")
until_transaction_id = BindParameter("until_transaction_id", value=until_transaction.id)
else:
until_transaction_id = None
if not (after_time is None or after_transaction is None):
raise ValueError("Cannot filter by both after_time and after_transaction_id.")
if isinstance(after_time, datetime):
after_time = BindParameter("after_time", value=after_time)
if isinstance(after_transaction, Transaction):
if after_transaction.id is None:
raise ValueError("after_transaction must be persisted in the database.")
after_transaction_id = BindParameter("after_transaction_id", value=after_transaction.id)
else:
after_transaction_id = None
if after_time is not None and until_time is not None:
assert isinstance(after_time.value, datetime)
assert isinstance(until_time.value, datetime)
if after_time.value > until_time.value:
raise ValueError("after_time cannot be after until_time.")
if after_transaction is not None and until_transaction is not None:
assert after_transaction.time is not None
assert until_transaction.time is not None
if after_transaction.time > until_transaction.time:
raise ValueError("after_transaction cannot be after until_transaction.")
result = sql_session.scalars(
select(Product)
.distinct()
.join(Transaction, Product.id == Transaction.product_id)
.where(
Transaction.type_.in_(
[
TransactionType.ADD_PRODUCT.as_literal_column(),
TransactionType.ADJUST_STOCK.as_literal_column(),
TransactionType.BUY_PRODUCT.as_literal_column(),
TransactionType.JOINT.as_literal_column(),
TransactionType.THROW_PRODUCT.as_literal_column(),
]
),
until_filter(
until_time=until_time,
until_transaction_id=until_transaction_id,
until_inclusive=until_inclusive,
),
after_filter(
after_time=after_time,
after_transaction_id=after_transaction_id,
after_inclusive=after_inclusive,
),
)
.order_by(Transaction.time.desc())
).all()
return set(result)

View File

@@ -1,86 +0,0 @@
from datetime import datetime
from sqlalchemy import BindParameter, select, or_
from sqlalchemy.orm import Session
from dibbler.models import Transaction, TransactionType, User
from dibbler.queries.query_helpers import until_filter, after_filter
def affected_users(
sql_session: Session,
until_time: BindParameter[datetime] | datetime | None = None,
until_transaction: BindParameter[Transaction] | Transaction | None = None,
until_inclusive: bool = True,
after_time: BindParameter[datetime] | datetime | None = None,
after_transaction: Transaction | None = None,
after_inclusive: bool = True,
) -> set[User]:
"""
Get all users where attributes were affected over a given interval.
"""
if isinstance(until_time, datetime):
until_time = BindParameter("until_time", value=until_time)
if isinstance(until_transaction, Transaction):
if until_transaction.id is None:
raise ValueError("until_transaction must be persisted in the database.")
until_transaction_id = BindParameter("until_transaction_id", value=until_transaction.id)
else:
until_transaction_id = None
if not (after_time is None or after_transaction is None):
raise ValueError("Cannot filter by both after_time and after_transaction_id.")
if isinstance(after_time, datetime):
after_time = BindParameter("after_time", value=after_time)
if isinstance(after_transaction, Transaction):
if after_transaction.id is None:
raise ValueError("after_transaction must be persisted in the database.")
after_transaction_id = BindParameter("after_transaction_id", value=after_transaction.id)
else:
after_transaction_id = None
if after_time is not None and until_time is not None:
assert isinstance(after_time.value, datetime)
assert isinstance(until_time.value, datetime)
if after_time.value > until_time.value:
raise ValueError("after_time cannot be after until_time.")
if after_transaction is not None and until_transaction is not None:
assert after_transaction.time is not None
assert until_transaction.time is not None
if after_transaction.time > until_transaction.time:
raise ValueError("after_transaction cannot be after until_transaction.")
result = sql_session.scalars(
select(User)
.distinct()
.join(Transaction, or_(User.id == Transaction.user_id, User.id == Transaction.transfer_user_id))
.where(
Transaction.type_.in_(
[
TransactionType.ADD_PRODUCT.as_literal_column(),
TransactionType.ADJUST_BALANCE.as_literal_column(),
TransactionType.BUY_PRODUCT.as_literal_column(),
TransactionType.TRANSFER.as_literal_column(),
]
),
until_filter(
until_time=until_time,
until_transaction_id=until_transaction_id,
until_inclusive=until_inclusive,
),
after_filter(
after_time=after_time,
after_transaction_id=after_transaction_id,
after_inclusive=after_inclusive,
),
)
.order_by(Transaction.time.desc())
).all()
return set(result)

View File

@@ -1,38 +0,0 @@
from datetime import datetime
from sqlalchemy.orm import Session
from dibbler.models import Product, Transaction, User
def buy_product(
sql_session: Session,
user: User,
product: Product,
product_count: int,
time: datetime | None = None,
message: str | None = None,
) -> Transaction:
if user.id is None:
raise ValueError("User must be persisted in the database.")
if product.id is None:
raise ValueError("Product must be persisted in the database.")
if product_count <= 0:
raise ValueError("Product count must be positive.")
# TODO: verify time is not behind last transaction's time
transaction = Transaction.buy_product(
user_id=user.id,
product_id=product.id,
product_count=product_count,
time=time,
message=message,
)
sql_session.add(transaction)
sql_session.commit()
return transaction

View File

@@ -1,25 +0,0 @@
from sqlalchemy.orm import Session
from dibbler.models import Product
def create_product(
sql_session: Session,
name: str,
barcode: str,
) -> Product:
if not name:
raise ValueError("Name cannot be empty.")
if not barcode:
raise ValueError("Barcode cannot be empty.")
# TODO: check for duplicate names, barcodes
# TODO: add more validation for barcode
product = Product(barcode, name)
sql_session.add(product)
sql_session.commit()
return product

View File

@@ -1,21 +0,0 @@
from sqlalchemy.orm import Session
from dibbler.models import User
def create_user(
sql_session: Session,
name: str,
card: str | None,
rfid: str | None,
) -> User:
if not name:
raise ValueError("Name cannot be empty.")
# TODO: check for duplicate names, cards, rfids
user = User(name=name, card=card, rfid=rfid)
sql_session.add(user)
sql_session.commit()
return user

View File

@@ -1,55 +1,24 @@
from datetime import datetime
from sqlalchemy import BindParameter, bindparam, select
from sqlalchemy import select
from sqlalchemy.orm import Session
from dibbler.models import Transaction, TransactionType
from dibbler.models.Transaction import DEFAULT_INTEREST_RATE_PERCENT
from dibbler.queries.query_helpers import until_filter
from dibbler.models.Transaction import DEFAULT_INTEREST_RATE_PERCENTAGE
def current_interest(
sql_session: Session,
until_time: BindParameter[datetime] | datetime | None = None,
until_transaction: BindParameter[Transaction] | Transaction | None = None,
until_inclusive: bool = True,
) -> int:
"""
Get the current interest rate percentage as of a given time or transaction.
Returns the interest rate percentage as an integer.
"""
if not (until_time is None or until_transaction is None):
raise ValueError("Cannot filter by both until_time and until_transaction.")
if isinstance(until_time, datetime):
until_time = BindParameter("until_time", value=until_time)
if isinstance(until_transaction, Transaction):
if until_transaction.id is None:
raise ValueError("until_transaction must be persisted in the database.")
until_transaction_id = bindparam("until_transaction_id", value=until_transaction.id)
else:
until_transaction_id = None
# TODO: add until transaction parameter
# TODO: add until datetime parameter
def current_interest(sql_session: Session) -> int:
result = sql_session.scalars(
select(Transaction)
.where(
Transaction.type_ == TransactionType.ADJUST_INTEREST,
until_filter(
until_time=until_time,
until_transaction_id=until_transaction_id,
until_inclusive=until_inclusive,
),
)
.where(Transaction.type_ == TransactionType.ADJUST_INTEREST)
.order_by(Transaction.time.desc())
.limit(1)
).one_or_none()
if result is None:
return DEFAULT_INTEREST_RATE_PERCENT
return DEFAULT_INTEREST_RATE_PERCENTAGE
elif result.interest_rate_percent is None:
return DEFAULT_INTEREST_RATE_PERCENT
return DEFAULT_INTEREST_RATE_PERCENTAGE
else:
return result.interest_rate_percent

View File

@@ -1,57 +1,26 @@
from datetime import datetime
from sqlalchemy import BindParameter, bindparam, select
from sqlalchemy import select
from sqlalchemy.orm import Session
from dibbler.models import Transaction, TransactionType
from dibbler.models.Transaction import (
DEFAULT_PENALTY_MULTIPLIER_PERCENT,
DEFAULT_PENALTY_MULTIPLIER_PERCENTAGE,
DEFAULT_PENALTY_THRESHOLD,
)
from dibbler.queries.query_helpers import until_filter
def current_penalty(
sql_session: Session,
until_time: BindParameter[datetime] | datetime | None = None,
until_transaction: BindParameter[Transaction] | Transaction | None = None,
until_inclusive: bool = True,
) -> tuple[int, int]:
"""
Get the current penalty settings (threshold and multiplier percentage) as of a given time or transaction.
Returns a tuple of `(penalty_threshold, penalty_multiplier_percentage)`.
"""
if not (until_time is None or until_transaction is None):
raise ValueError("Cannot filter by both until_time and until_transaction.")
if isinstance(until_time, datetime):
until_time = BindParameter("until_time", value=until_time)
if isinstance(until_transaction, Transaction):
if until_transaction.id is None:
raise ValueError("until_transaction must be persisted in the database.")
until_transaction_id = bindparam("until_transaction_id", value=until_transaction.id)
else:
until_transaction_id = None
# TODO: add until transaction parameter
# TODO: add until datetime parameter
def current_penalty(sql_session: Session) -> tuple[int, int]:
result = sql_session.scalars(
select(Transaction)
.where(
Transaction.type_ == TransactionType.ADJUST_PENALTY,
until_filter(
until_time=until_time,
until_transaction_id=until_transaction_id,
until_inclusive=until_inclusive,
),
)
.where(Transaction.type_ == TransactionType.ADJUST_PENALTY)
.order_by(Transaction.time.desc())
.limit(1)
).one_or_none()
if result is None:
return DEFAULT_PENALTY_THRESHOLD, DEFAULT_PENALTY_MULTIPLIER_PERCENT
return DEFAULT_PENALTY_THRESHOLD, DEFAULT_PENALTY_MULTIPLIER_PERCENTAGE
assert result.penalty_threshold is not None, "Penalty threshold must be set"
assert result.penalty_multiplier_percent is not None, "Penalty multiplier percent must be set"

View File

@@ -17,7 +17,7 @@ def joint_buy_product(
users: list[User],
time: datetime | None = None,
message: str | None = None,
) -> list[Transaction]:
) -> None:
"""
Create buy product transactions for multiple users at once.
"""
@@ -25,23 +25,15 @@ def joint_buy_product(
if product.id is None:
raise ValueError("Product must be persisted in the database.")
if instigator.id is None:
raise ValueError("Instigator must be persisted in the database.")
if len(users) == 0:
raise ValueError("At least bying one user must be specified.")
if instigator not in users:
raise ValueError("Instigator must be in the list of users buying the product.")
if any(user.id is None for user in users):
raise ValueError("All users must be persisted in the database.")
if instigator not in users:
raise ValueError("Instigator must be in the list of users buying the product.")
if product_count <= 0:
raise ValueError("Product count must be positive.")
# TODO: verify time is not behind last transaction's time
joint_transaction = Transaction.joint(
user_id=instigator.id,
product_id=product.id,
@@ -52,8 +44,6 @@ def joint_buy_product(
sql_session.add(joint_transaction)
sql_session.flush() # Ensure joint_transaction gets an ID
transactions = [joint_transaction]
for user in users:
buy_transaction = Transaction.joint_buy_product(
user_id=user.id,
@@ -62,7 +52,5 @@ def joint_buy_product(
message=message,
)
sql_session.add(buy_transaction)
transactions.append(buy_transaction)
sql_session.commit()
return transactions

View File

@@ -8,11 +8,11 @@ from sqlalchemy import (
bindparam,
case,
func,
or_,
select,
)
from sqlalchemy.orm import Session
from dibbler.lib.query_helpers import CONST_NONE, CONST_ONE, CONST_TRUE, CONST_ZERO
from dibbler.models import (
Product,
Transaction,
@@ -20,22 +20,13 @@ from dibbler.models import (
User,
)
from dibbler.queries.product_stock import _product_stock_query
from dibbler.queries.query_helpers import (
CONST_NONE,
CONST_ONE,
CONST_ZERO,
until_filter,
)
def _product_owners_query(
product_id: BindParameter[int] | int,
use_cache: bool = True,
until_time: BindParameter[datetime] | datetime | None = None,
until_transaction: Transaction | None = None,
until_inclusive: bool = True,
until: BindParameter[datetime] | datetime | None = None,
cte_name: str = "rec_cte",
trx_subset_name: str = "trx_subset",
) -> CTE:
"""
The inner query for inferring the owners of a given product.
@@ -47,25 +38,13 @@ def _product_owners_query(
if isinstance(product_id, int):
product_id = bindparam("product_id", value=product_id)
if until_time is not None and until_transaction is not None:
raise ValueError("Cannot filter by both until_time and until_transaction.")
if isinstance(until_time, datetime):
until_time = bindparam("until_time", value=until_time)
if isinstance(until_transaction, Transaction):
if until_transaction.id is None:
raise ValueError("until_transaction must be persisted in the database.")
until_transaction_id = bindparam("until_transaction_id", value=until_transaction.id)
else:
until_transaction_id = None
if isinstance(until, datetime):
until = BindParameter("until", value=until)
product_stock = _product_stock_query(
product_id=product_id,
use_cache=use_cache,
until_time=until_time,
until_transaction=until_transaction,
until_inclusive=until_inclusive,
until=until,
)
# Subset of transactions that we'll want to iterate over.
@@ -78,23 +57,22 @@ def _product_owners_query(
Transaction.user_id,
Transaction.product_count,
)
# TODO: maybe add value constraint on ADJUST_STOCK?
.where(
or_(
Transaction.type_ == TransactionType.ADD_PRODUCT.as_literal_column(),
and_(
Transaction.type_ == TransactionType.ADJUST_STOCK.as_literal_column(),
Transaction.product_count > CONST_ZERO,
),
Transaction.type_.in_(
[
TransactionType.ADD_PRODUCT.as_literal_column(),
# TransactionType.BUY_PRODUCT,
TransactionType.ADJUST_STOCK.as_literal_column(),
# TransactionType.JOINT,
# TransactionType.THROW_PRODUCT,
]
),
Transaction.product_id == product_id,
until_filter(
until_time=until_time,
until_transaction_id=until_transaction_id,
until_inclusive=until_inclusive,
),
CONST_TRUE if until is None else Transaction.time <= until,
)
.order_by(Transaction.time.desc())
.subquery(trx_subset_name)
.subquery()
)
initial_element = select(
@@ -139,19 +117,35 @@ def _product_owners_query(
).label("product_count"),
# How many products left to account for
case(
# Someone adds the product -> known owner, decrease the number of products left to account for
# Someone adds the product -> increase the number of products left to account for
(
trx_subset.c.type_ == TransactionType.ADD_PRODUCT.as_literal_column(),
recursive_cte.c.products_left_to_account_for - trx_subset.c.product_count,
),
# Stock got adjusted upwards -> none owner, decrease the number of products left to account for
# Someone buys/joins/throws the product -> decrease the number of products left to account for
# (
# trx_subset.c.type_.in_(
# [
# TransactionType.BUY_PRODUCT,
# TransactionType.JOINT,
# TransactionType.THROW_PRODUCT,
# ]
# ),
# recursive_cte.c.products_left_to_account_for - trx_subset.c.product_count,
# ),
# Someone adjusts the stock ->
# If adjusted upwards -> products owned by nobody, decrease products left to account for
# If adjusted downwards -> products taken away from owners, decrease products left to account for
(
and_(
trx_subset.c.type_ == TransactionType.ADJUST_STOCK.as_literal_column(),
trx_subset.c.product_count > CONST_ZERO,
),
(trx_subset.c.type_ == TransactionType.ADJUST_STOCK.as_literal_column())
and (trx_subset.c.product_count > CONST_ZERO),
recursive_cte.c.products_left_to_account_for - trx_subset.c.product_count,
),
# (
# (trx_subset.c.type_ == TransactionType.ADJUST_STOCK)
# and (trx_subset.c.product_count < 0),
# recursive_cte.c.products_left_to_account_for + trx_subset.c.product_count,
# ),
else_=recursive_cte.c.products_left_to_account_for,
).label("products_left_to_account_for"),
)
@@ -159,7 +153,6 @@ def _product_owners_query(
.where(
and_(
trx_subset.c.i == recursive_cte.c.i + CONST_ONE,
# Base case: stop if we've accounted for all products
recursive_cte.c.products_left_to_account_for > CONST_ZERO,
)
)
@@ -174,14 +167,13 @@ class ProductOwnersLogEntry:
user: User | None
products_left_to_account_for: int
# TODO: add until datetime parameter
def product_owners_log(
sql_session: Session,
product: Product,
use_cache: bool = True,
until_time: BindParameter[datetime] | datetime | None = None,
until_transaction: Transaction | None = None,
until_inclusive: bool = True,
until: Transaction | None = None,
) -> list[ProductOwnersLogEntry]:
"""
Returns a log of the product ownership calculation for the given product.
@@ -192,12 +184,13 @@ def product_owners_log(
if product.id is None:
raise ValueError("Product must be persisted in the database.")
if until is not None and until.id is None:
raise ValueError("'until' transaction must be persisted in the database.")
recursive_cte = _product_owners_query(
product_id=product.id,
use_cache=use_cache,
until_time=until_time,
until_transaction=until_transaction,
until_inclusive=until_inclusive,
until=until.time if until else None,
)
result = sql_session.execute(
@@ -235,13 +228,13 @@ def product_owners_log(
]
# TODO: add until transaction parameter
def product_owners(
sql_session: Session,
product: Product,
use_cache: bool = True,
until_time: BindParameter[datetime] | datetime | None = None,
until_transaction: Transaction | None = None,
until_inclusive: bool = True,
until: datetime | None = None,
) -> list[User | None]:
"""
Returns an ordered list of users owning the given product.
@@ -255,9 +248,7 @@ def product_owners(
recursive_cte = _product_owners_query(
product_id=product.id,
use_cache=use_cache,
until_time=until_time,
until_transaction=until_transaction,
until_inclusive=until_inclusive,
until=until,
)
db_result = sql_session.execute(
@@ -301,7 +292,7 @@ def product_owners(
result.extend([None] * none_count)
# # NOTE: if the last line exceeds the product count, we need to truncate it
# # NOTE: if the last line exeeds the product count, we need to truncate it
# result.extend([user] * min(user_count, products_left_to_account_for))
# redistribute the user counts to a list of users

View File

@@ -6,7 +6,7 @@ from sqlalchemy import (
BindParameter,
ColumnElement,
Integer,
bindparam,
asc,
case,
cast,
func,
@@ -14,110 +14,52 @@ from sqlalchemy import (
)
from sqlalchemy.orm import Session
from dibbler.lib.query_helpers import CONST_NONE, CONST_ONE, CONST_TRUE, CONST_ZERO
from dibbler.models import (
LastCacheTransaction,
Product,
ProductCache,
Transaction,
TransactionType,
)
from dibbler.models.Transaction import DEFAULT_INTEREST_RATE_PERCENT
from dibbler.queries.query_helpers import (
CONST_NONE,
CONST_ONE,
CONST_ZERO,
until_filter, after_filter,
)
from dibbler.models.Transaction import DEFAULT_INTEREST_RATE_PERCENTAGE
def _product_price_query(
product_id: int | ColumnElement[int],
use_cache: bool = True,
until_time: BindParameter[datetime] | datetime | None = None,
until_transaction: Transaction | None = None,
until_inclusive: bool = True,
until: BindParameter[datetime] | datetime | None = None,
until_including: BindParameter[bool] | bool = True,
cte_name: str = "rec_cte",
trx_subset_name: str = "trx_subset",
):
"""
The inner query for calculating the product price.
"""
if use_cache:
print("WARNING: Using cache for product price query is not implemented yet.")
if isinstance(product_id, int):
product_id = BindParameter("product_id", value=product_id)
if not (until_time is None or until_transaction is None):
raise ValueError("Cannot filter by both until_time and until_transaction.")
if isinstance(until, datetime):
until = BindParameter("until", value=until)
if isinstance(until_time, datetime):
until_time = BindParameter("until_time", value=until_time)
if isinstance(until_including, bool):
until_including = BindParameter("until_including", value=until_including)
if isinstance(until_transaction, Transaction):
if until_transaction.id is None:
raise ValueError("until_transaction must be persisted in the database.")
until_transaction_id = bindparam("until_transaction_id", value=until_transaction.id)
else:
until_transaction_id = None
if use_cache:
initial_element_fields = (
select(
Transaction.time.label("time"),
Transaction.id.label("transaction_id"),
ProductCache.price.label("price"),
ProductCache.stock.label("product_count"),
)
.select_from(ProductCache)
.join(
LastCacheTransaction,
ProductCache.last_cache_transaction_id == LastCacheTransaction.id,
)
.join(Transaction, LastCacheTransaction.transaction_id == Transaction.id)
.where(
ProductCache.product_id == product_id,
until_filter(
until_time=until_time,
until_transaction_id=until_transaction_id,
until_inclusive=until_inclusive,
),
)
.union(
select(
CONST_ZERO.label("time"),
CONST_NONE.label("transaction_id"),
CONST_ZERO.label("price"),
CONST_ZERO.label("product_count"),
)
)
.order_by(Transaction.time.desc())
.limit(CONST_ONE)
.offset(CONST_ZERO)
.subquery()
.alias("initial_element_fields")
)
initial_element = select(
CONST_ZERO.label("i"),
initial_element_fields.c.time,
initial_element_fields.c.transaction_id,
initial_element_fields.c.price,
initial_element_fields.c.product_count,
).select_from(initial_element_fields)
else:
initial_element = select(
CONST_ZERO.label("i"),
CONST_ZERO.label("time"),
CONST_NONE.label("transaction_id"),
CONST_ZERO.label("price"),
CONST_ZERO.label("product_count"),
)
initial_element = select(
CONST_ZERO.label("i"),
CONST_ZERO.label("time"),
CONST_NONE.label("transaction_id"),
CONST_ZERO.label("price"),
CONST_ZERO.label("product_count"),
)
recursive_cte = initial_element.cte(name=cte_name, recursive=True)
# Subset of transactions that we'll want to iterate over.
trx_subset = (
select(
func.row_number().over(order_by=Transaction.time.asc()).label("i"),
func.row_number().over(order_by=asc(Transaction.time)).label("i"),
Transaction.id,
Transaction.time,
Transaction.type_,
@@ -134,19 +76,15 @@ def _product_price_query(
]
),
Transaction.product_id == product_id,
after_filter(
after_time=None,
after_transaction_id=recursive_cte.c.transaction_id,
after_inclusive=False,
),
until_filter(
until_time=until_time,
until_transaction_id=until_transaction_id,
until_inclusive=until_inclusive,
),
case(
(until_including, Transaction.time <= until),
else_=Transaction.time < until,
)
if until is not None
else CONST_TRUE,
)
.order_by(Transaction.time.asc())
.subquery(trx_subset_name)
.alias("trx_subset")
)
recursive_elements = (
@@ -232,13 +170,13 @@ class ProductPriceLogEntry:
product_count: int
# TODO: add until datetime parameter
def product_price_log(
sql_session: Session,
product: Product,
use_cache: bool = True,
until_time: BindParameter[datetime] | datetime | None = None,
until_transaction: Transaction | None = None,
until_inclusive: bool = True,
until: Transaction | None = None,
) -> list[ProductPriceLogEntry]:
"""
Calculates the price of a product and returns a log of the price changes.
@@ -247,12 +185,13 @@ def product_price_log(
if product.id is None:
raise ValueError("Product must be persisted in the database.")
if until is not None and until.id is None:
raise ValueError("'until' transaction must be persisted in the database.")
recursive_cte = _product_price_query(
product.id,
use_cache=use_cache,
until_time=until_time,
until_transaction=until_transaction,
until_inclusive=until_inclusive,
until=until.time if until else None,
)
result = sql_session.execute(
@@ -285,13 +224,13 @@ def product_price_log(
]
# TODO: add until datetime parameter
def product_price(
sql_session: Session,
product: Product,
use_cache: bool = True,
until_time: BindParameter[datetime] | datetime | None = None,
until_transaction: Transaction | None = None,
until_inclusive: bool = True,
until: Transaction | None = None,
include_interest: bool = False,
) -> int:
"""
@@ -301,22 +240,13 @@ def product_price(
if product.id is None:
raise ValueError("Product must be persisted in the database.")
if isinstance(until_time, datetime):
until_time = BindParameter("until_time", value=until_time)
if isinstance(until_transaction, Transaction):
if until_transaction.id is None:
raise ValueError("until_transaction must be persisted in the database.")
until_transaction_id = bindparam("until_transaction_id", value=until_transaction.id)
else:
until_transaction_id = None
if until is not None and until.id is None:
raise ValueError("'until' transaction must be persisted in the database.")
recursive_cte = _product_price_query(
product.id,
use_cache=use_cache,
until_time=until_time,
until_transaction=until_transaction,
until_inclusive=until_inclusive,
until=until.time if until else None,
)
# TODO: optionally verify subresults:
@@ -342,16 +272,12 @@ def product_price(
select(Transaction.interest_rate_percent)
.where(
Transaction.type_ == TransactionType.ADJUST_INTEREST,
until_filter(
until_time=until_time,
until_transaction_id=until_transaction_id,
until_inclusive=until_inclusive,
),
CONST_TRUE if until is None else Transaction.time <= until.time,
)
.order_by(Transaction.time.desc())
.limit(CONST_ONE)
)
or DEFAULT_INTEREST_RATE_PERCENT
or DEFAULT_INTEREST_RATE_PERCENTAGE
)
result = math.ceil(result * interest_rate / 100)

View File

@@ -1,31 +1,27 @@
from datetime import datetime
from typing import Tuple
from sqlalchemy import (
BindParameter,
Select,
bindparam,
case,
func,
select,
)
from sqlalchemy.orm import Session
from dibbler.lib.query_helpers import CONST_TRUE
from dibbler.models import (
Product,
Transaction,
TransactionType,
)
from dibbler.queries.query_helpers import until_filter
def _product_stock_query(
product_id: BindParameter[int] | int,
use_cache: bool = True,
until_time: BindParameter[datetime] | datetime | None = None,
until_transaction: Transaction | None = None,
until_inclusive: bool = True,
) -> Select[Tuple[int]]:
until: BindParameter[datetime] | datetime | None = None,
) -> Select:
"""
The inner query for calculating the product stock.
"""
@@ -36,18 +32,8 @@ def _product_stock_query(
if isinstance(product_id, int):
product_id = BindParameter("product_id", value=product_id)
if not (until_time is None or until_transaction is None):
raise ValueError("Cannot filter by both until_time and until_transaction.")
if isinstance(until_time, datetime):
until_time = BindParameter("until_time", value=until_time)
if isinstance(until_transaction, Transaction):
if until_transaction.id is None:
raise ValueError("until_transaction must be persisted in the database.")
until_transaction_id = bindparam("until_transaction_id", value=until_transaction.id)
else:
until_transaction_id = None
if isinstance(until, datetime):
until = BindParameter("until", value=until)
query = select(
func.sum(
@@ -74,7 +60,7 @@ def _product_stock_query(
),
else_=0,
)
).label("stock")
)
).where(
Transaction.type_.in_(
[
@@ -86,23 +72,19 @@ def _product_stock_query(
]
),
Transaction.product_id == product_id,
until_filter(
until_time=until_time,
until_transaction_id=until_transaction_id,
until_inclusive=until_inclusive,
),
Transaction.time <= until if until is not None else CONST_TRUE,
)
return query
# TODO: add until transaction parameter
def product_stock(
sql_session: Session,
product: Product,
use_cache: bool = True,
until_time: BindParameter[datetime] | datetime | None = None,
until_transaction: Transaction | None = None,
until_inclusive: bool = True,
until: datetime | None = None,
) -> int:
"""
Returns the number of products in stock.
@@ -116,9 +98,7 @@ def product_stock(
query = _product_stock_query(
product_id=product.id,
use_cache=use_cache,
until_time=until_time,
until_transaction=until_transaction,
until_inclusive=until_inclusive,
until=until,
)
result = sql_session.scalars(query).one_or_none()

View File

@@ -1,119 +0,0 @@
from datetime import datetime
from typing import TypeVar
from sqlalchemy import (
BindParameter,
ColumnExpressionArgument,
literal,
select,
)
from sqlalchemy.orm import QueryableAttribute
from dibbler.models import Transaction
T = TypeVar("T")
def const(value: T) -> BindParameter[T]:
"""
Create a constant SQL literal bind parameter.
This is useful to avoid too many `?` bind parameters in SQL queries,
when the input value is known to be safe.
"""
return literal(value, literal_execute=True)
CONST_ZERO: BindParameter[int] = const(0)
"""A constant SQL expression `0`. This will render as a literal `0` in SQL queries."""
CONST_ONE: BindParameter[int] = const(1)
"""A constant SQL expression `1`. This will render as a literal `1` in SQL queries."""
CONST_TRUE: BindParameter[bool] = const(True)
"""A constant SQL expression `TRUE`. This will render as a literal `TRUE` in SQL queries."""
CONST_FALSE: BindParameter[bool] = const(False)
"""A constant SQL expression `FALSE`. This will render as a literal `FALSE` in SQL queries."""
CONST_NONE: BindParameter[None] = const(None)
"""A constant SQL expression `NULL`. This will render as a literal `NULL` in SQL queries."""
def until_filter(
until_time: BindParameter[datetime] | None = None,
until_transaction_id: BindParameter[int] | None = None,
until_inclusive: bool = True,
transaction_time: QueryableAttribute = Transaction.time,
) -> ColumnExpressionArgument[bool]:
"""
Create a filter condition for transactions up to a given time or transaction.
Only one of `until_time` or `until_transaction_id` may be specified.
"""
assert not (until_time is not None and until_transaction_id is not None), (
"Cannot filter by both until_time and until_transaction_id."
)
match (until_time, until_transaction_id, until_inclusive):
case (BindParameter(), None, True):
return transaction_time <= until_time
case (BindParameter(), None, False):
return transaction_time < until_time
case (None, BindParameter(), True):
return (
transaction_time
<= select(Transaction.time)
.where(Transaction.id == until_transaction_id)
.scalar_subquery()
)
case (None, BindParameter(), False):
return (
transaction_time
< select(Transaction.time)
.where(Transaction.id == until_transaction_id)
.scalar_subquery()
)
return CONST_TRUE
def after_filter(
after_time: BindParameter[datetime] | None = None,
after_transaction_id: BindParameter[int] | None = None,
after_inclusive: bool = True,
transaction_time: QueryableAttribute = Transaction.time,
) -> ColumnExpressionArgument[bool]:
"""
Create a filter condition for transactions after a given time or transaction.
Only one of `after_time` or `after_transaction_id` may be specified.
"""
assert not (after_time is not None and after_transaction_id is not None), (
"Cannot filter by both after_time and after_transaction_id."
)
match (after_time, after_transaction_id, after_inclusive):
case (BindParameter(), None, True):
return transaction_time >= after_time
case (BindParameter(), None, False):
return transaction_time > after_time
case (None, BindParameter(), True):
return (
transaction_time
>= select(Transaction.time)
.where(Transaction.id == after_transaction_id)
.scalar_subquery()
)
case (None, BindParameter(), False):
return (
transaction_time
> select(Transaction.time)
.where(Transaction.id == after_transaction_id)
.scalar_subquery()
)
return CONST_TRUE

View File

@@ -9,9 +9,6 @@ def search_product(
sql_session: Session,
find_hidden_products=False,
) -> Product | list[Product]:
if not string:
raise ValueError("Search string cannot be empty.")
exact_match = sql_session.scalars(
select(Product).where(
or_(

View File

@@ -8,9 +8,6 @@ def search_user(
string: str,
sql_session: Session,
) -> User | list[User]:
if not string:
raise ValueError("Search string cannot be empty.")
string = string.lower()
exact_match = sql_session.scalars(

View File

@@ -1,42 +0,0 @@
from datetime import datetime
from sqlalchemy.orm import Session
from dibbler.models import Product, Transaction, User
def throw_product(
sql_session: Session,
user: User,
product: Product,
product_count: int,
time: datetime | None = None,
message: str | None = None,
) -> Transaction:
if user.id is None:
raise ValueError("User must be persisted in the database.")
if product.id is None:
raise ValueError("Product must be persisted in the database.")
if product_count <= 0:
raise ValueError("Product count must be positive.")
# TODO: verify time is not behind last transaction's time
raise NotImplementedError(
"Please don't use this function until relevant calculations have been added to user_balance."
)
transaction = Transaction.throw_product(
user_id=user.id,
product_id=product.id,
product_count=product_count,
time=time,
message=message,
)
sql_session.add(transaction)
sql_session.commit()
return transaction

View File

@@ -1,6 +1,4 @@
from datetime import datetime
from sqlalchemy import BindParameter, select
from sqlalchemy import select
from sqlalchemy.orm import Session
from dibbler.models import (
@@ -17,12 +15,12 @@ def transaction_log(
sql_session: Session,
user: User | None = None,
product: Product | None = None,
until_time: BindParameter[datetime] | datetime | None = None,
until_transaction: Transaction | None = None,
until_inclusive: bool = True,
after_time: BindParameter[datetime] | datetime | None = None,
after_transaction: Transaction | None = None,
after_inclusive: bool = True,
exclusive_after: bool = False,
after_time=None,
after_transaction_id: int | None = None,
exclusive_before: bool = False,
before_time=None,
before_transaction_id: int | None = None,
transaction_type: list[TransactionType] | None = None,
negate_transaction_type_filter: bool = False,
limit: int | None = None,
@@ -31,101 +29,51 @@ def transaction_log(
Retrieve the transaction log, optionally filtered.
Only one of `user` or `product` may be specified.
Only one of `until_time` or `until_transaction_id` may be specified.
Only one of `after_time` or `after_transaction_id` may be specified.
Only one of `before_time` or `before_transaction_id` may be specified.
The after and after filters are inclusive by default.
The before and after filters are inclusive by default.
"""
if not (user is None or product is None):
raise ValueError("Cannot filter by both user and product.")
if isinstance(user, User):
if user.id is None:
raise ValueError("User must be persisted in the database.")
user_id = BindParameter("user_id", value=user.id)
else:
user_id = None
if user is not None and user.id is None:
raise ValueError("User must be persisted in the database.")
if isinstance(product, Product):
if product.id is None:
raise ValueError("Product must be persisted in the database.")
product_id = BindParameter("product_id", value=product.id)
else:
product_id = None
if product is not None and product.id is None:
raise ValueError("Product must be persisted in the database.")
if not (until_time is None or until_transaction is None):
raise ValueError("Cannot filter by both after_time and after_transaction_id.")
if isinstance(until_time, datetime):
until_time = BindParameter("until_time", value=until_time)
if isinstance(until_transaction, Transaction):
if until_transaction.id is None:
raise ValueError("until_transaction must be persisted in the database.")
until_transaction_id = BindParameter("until_transaction_id", value=until_transaction.id)
else:
until_transaction_id = None
if not (after_time is None or after_transaction is None):
raise ValueError("Cannot filter by both after_time and after_transaction_id.")
if isinstance(after_time, datetime):
after_time = BindParameter("after_time", value=after_time)
if isinstance(after_transaction, Transaction):
if after_transaction.id is None:
raise ValueError("after_transaction must be persisted in the database.")
after_transaction_id = BindParameter("after_transaction_id", value=after_transaction.id)
else:
after_transaction_id = None
if after_time is not None and until_time is not None:
assert isinstance(after_time.value, datetime)
assert isinstance(until_time.value, datetime)
if after_time.value > until_time.value:
raise ValueError("after_time cannot be after until_time.")
if after_transaction is not None and until_transaction is not None:
assert after_transaction.time is not None
assert until_transaction.time is not None
if after_transaction.time > until_transaction.time:
raise ValueError("after_transaction cannot be after until_transaction.")
if limit is not None and limit <= 0:
raise ValueError("Limit must be positive.")
if not (after_time is None or after_transaction_id is None):
raise ValueError("Cannot filter by both from_time and from_transaction_id.")
query = select(Transaction)
if user is not None:
query = query.where(Transaction.user_id == user_id)
query = query.where(Transaction.user_id == user.id)
if product is not None:
query = query.where(Transaction.product_id == product_id)
query = query.where(Transaction.product_id == product.id)
match (until_time, until_transaction_id, until_inclusive):
case (BindParameter(), None, True):
query = query.where(Transaction.time <= until_time)
case (BindParameter(), None, False):
query = query.where(Transaction.time < until_time)
case (None, BindParameter(), True):
query = query.where(Transaction.id <= until_transaction_id)
case (None, BindParameter(), False):
query = query.where(Transaction.id < until_transaction_id)
case _:
pass
match (after_time, after_transaction_id, after_inclusive):
case (BindParameter(), None, True):
query = query.where(Transaction.time >= after_time)
case (BindParameter(), None, False):
if after_time is not None:
if exclusive_after:
query = query.where(Transaction.time > after_time)
case (None, BindParameter(), True):
query = query.where(Transaction.id >= after_transaction_id)
case (None, BindParameter(), False):
else:
query = query.where(Transaction.time >= after_time)
if after_transaction_id is not None:
if exclusive_after:
query = query.where(Transaction.id > after_transaction_id)
case _:
pass
else:
query = query.where(Transaction.id >= after_transaction_id)
if before_time is not None:
if exclusive_before:
query = query.where(Transaction.time < before_time)
else:
query = query.where(Transaction.time <= before_time)
if before_transaction_id is not None:
if exclusive_before:
query = query.where(Transaction.id < before_transaction_id)
else:
query = query.where(Transaction.id <= before_transaction_id)
if transaction_type is not None:
if negate_transaction_type_filter:

View File

@@ -1,38 +0,0 @@
from datetime import datetime
from sqlalchemy.orm import Session
from dibbler.models import Transaction, User
def transfer(
sql_session: Session,
from_user: User,
to_user: User,
amount: int,
time: datetime | None = None,
message: str | None = None,
) -> Transaction:
if from_user.id is None:
raise ValueError("From user must be persisted in the database.")
if to_user.id is None:
raise ValueError("To user must be persisted in the database.")
if amount <= 0:
raise ValueError("Amount must be positive.")
# TODO: verify time is not behind last transaction's time
transaction = Transaction.transfer(
user_id=from_user.id,
transfer_user_id=to_user.id,
amount=amount,
time=time,
message=message,
)
sql_session.add(transaction)
sql_session.commit()
return transaction

View File

@@ -1,118 +0,0 @@
from sqlalchemy import insert, select
from sqlalchemy.orm import Session
from dibbler.models import LastCacheTransaction, ProductCache, Transaction, UserCache
from dibbler.queries.affected_products import affected_products
from dibbler.queries.affected_users import affected_users
from dibbler.queries.product_price import product_price
from dibbler.queries.product_stock import product_stock
from dibbler.queries.user_balance import user_balance
def update_cache(
sql_session: Session,
use_cache: bool = True,
) -> None:
"""
Update the cache used for searching products.
If `use_cache` is False, the cache will be rebuilt from scratch.
"""
last_transaction = sql_session.scalars(
select(Transaction).order_by(Transaction.time.desc()).limit(1)
).one_or_none()
print(last_transaction)
if last_transaction is None:
# No transactions exist, nothing to update
return
if use_cache:
last_cache_transaction = sql_session.scalars(
select(LastCacheTransaction)
.join(Transaction, LastCacheTransaction.transaction_id == Transaction.id)
.order_by(Transaction.time.desc())
.limit(1)
).one_or_none()
if last_cache_transaction is not None:
last_cache_transaction = last_cache_transaction.transaction
else:
last_cache_transaction = None
if last_cache_transaction is not None and last_cache_transaction.id == last_transaction.id:
# Cache is already up to date
return
users = affected_users(
sql_session,
after_transaction=last_cache_transaction,
after_inclusive=False,
until_transaction=last_transaction,
)
products = affected_products(
sql_session,
after_transaction=last_cache_transaction,
after_inclusive=False,
until_transaction=last_transaction,
)
user_balances = {}
for user in users:
x = user_balance(
sql_session,
user,
use_cache=use_cache,
until_transaction=last_transaction,
)
user_balances[user.id] = x
product_stocks = {}
product_prices = {}
for product in products:
product_stocks[product.id] = product_stock(
sql_session,
product,
use_cache=use_cache,
until_transaction=last_transaction,
)
product_prices[product.id] = product_price(
sql_session,
product,
use_cache=use_cache,
until_transaction=last_transaction,
)
next_cache_transaction = LastCacheTransaction(transaction_id=last_transaction.id)
sql_session.add(next_cache_transaction)
sql_session.flush()
if not len(users) == 0:
sql_session.execute(
insert(UserCache),
[
{
"user_id": user.id,
"balance": user_balances[user.id],
"last_cache_transaction_id": next_cache_transaction.id,
}
for user in users
],
)
if not len(products) == 0:
sql_session.execute(
insert(ProductCache),
[
{
"product_id": product.id,
"stock": product_stocks[product.id],
"price": product_prices[product.id],
"last_cache_transaction_id": next_cache_transaction.id,
}
for product in products
],
)
sql_session.commit()

View File

@@ -1,15 +1,12 @@
from dataclasses import dataclass
from datetime import datetime
from typing import Tuple
from sqlalchemy import (
CTE,
BindParameter,
Float,
Integer,
Select,
and_,
bindparam,
case,
cast,
column,
@@ -17,243 +14,28 @@ from sqlalchemy import (
or_,
select,
)
from sqlalchemy.orm import Session, aliased
from sqlalchemy.sql.elements import KeyedColumnElement
from sqlalchemy.orm import Session
from dibbler.lib.query_helpers import CONST_NONE, CONST_ONE, CONST_TRUE, CONST_ZERO, const
from dibbler.models import (
Transaction,
TransactionType,
User,
)
from dibbler.models.Transaction import (
DEFAULT_INTEREST_RATE_PERCENT,
DEFAULT_PENALTY_MULTIPLIER_PERCENT,
DEFAULT_INTEREST_RATE_PERCENTAGE,
DEFAULT_PENALTY_MULTIPLIER_PERCENTAGE,
DEFAULT_PENALTY_THRESHOLD,
)
from dibbler.queries.product_price import _product_price_query
from dibbler.queries.query_helpers import (
CONST_NONE,
CONST_ONE,
CONST_ZERO,
const,
until_filter,
)
def _joint_transaction_query(
user_id: BindParameter[int] | int,
use_cache: bool = True,
until_time: BindParameter[datetime] | None = None,
until_transaction: Transaction | None = None,
until_inclusive: bool = True,
) -> Select[Tuple[int, int, int]]:
"""
The inner query for getting joint transactions relevant to a user.
This scans for JOINT_BUY_PRODUCT transactions made by the user,
then finds the corresponding JOINT transactions, and counts how many "shares"
of the joint transaction the user has, as well as the total number of shares.
"""
if isinstance(until_transaction, Transaction):
if until_transaction.id is None:
raise ValueError("until_transaction must be persisted in the database.")
until_transaction_id = bindparam("until_transaction_id", value=until_transaction.id)
else:
until_transaction_id = None
# First, select all joint buy product transactions for the given user
# sub_joint_transaction = aliased(Transaction, name="right_trx")
sub_joint_transaction = (
select(Transaction.joint_transaction_id.distinct().label("joint_transaction_id"))
.where(
Transaction.type_ == TransactionType.JOINT_BUY_PRODUCT.as_literal_column(),
Transaction.user_id == user_id,
until_filter(
until_time=until_time,
until_transaction_id=until_transaction_id,
until_inclusive=until_inclusive,
transaction_time=Transaction.time,
),
)
.subquery("sub_joint_transaction")
)
# Join those with their main joint transaction
# (just use Transaction)
# Then, count how many users are involved in each joint transaction
joint_transaction_count = aliased(Transaction, name="count_trx")
joint_transaction = (
select(
Transaction.id,
# Shares the user has in the transaction,
func.sum(
case(
(joint_transaction_count.user_id == user_id, CONST_ONE),
else_=CONST_ZERO,
)
).label("user_shares"),
# The total number of shares in the transaction,
func.count(joint_transaction_count.id).label("user_count"),
)
.select_from(sub_joint_transaction)
.join(
Transaction,
onclause=Transaction.id == sub_joint_transaction.c.joint_transaction_id,
)
.join(
joint_transaction_count,
onclause=joint_transaction_count.joint_transaction_id == Transaction.id,
)
.group_by(joint_transaction_count.joint_transaction_id)
)
return joint_transaction
def _non_joint_transaction_query(
user_id: BindParameter[int] | int,
use_cache: bool = True,
until_time: BindParameter[datetime] | None = None,
until_transaction: Transaction | None = None,
until_inclusive: bool = True,
) -> Select[Tuple[int, None, None]]:
"""
The inner query for getting non-joint transactions relevant to a user.
"""
if isinstance(until_transaction, Transaction):
if until_transaction.id is None:
raise ValueError("until_transaction must be persisted in the database.")
until_transaction_id = bindparam("until_transaction_id", value=until_transaction.id)
else:
until_transaction_id = None
query = select(
Transaction.id,
CONST_NONE.label("user_shares"),
CONST_NONE.label("user_count"),
).where(
or_(
and_(
Transaction.user_id == user_id,
Transaction.type_.in_(
[
TransactionType.ADD_PRODUCT.as_literal_column(),
TransactionType.ADJUST_BALANCE.as_literal_column(),
TransactionType.BUY_PRODUCT.as_literal_column(),
TransactionType.TRANSFER.as_literal_column(),
]
),
),
and_(
Transaction.type_ == TransactionType.TRANSFER.as_literal_column(),
Transaction.transfer_user_id == user_id,
),
Transaction.type_.in_(
[
TransactionType.THROW_PRODUCT.as_literal_column(),
TransactionType.ADJUST_INTEREST.as_literal_column(),
TransactionType.ADJUST_PENALTY.as_literal_column(),
]
),
),
until_filter(
until_time=until_time,
until_transaction_id=until_transaction_id,
until_inclusive=until_inclusive,
),
)
return query
def _product_cost_expression(
product_count_column: KeyedColumnElement[int],
product_id_column: KeyedColumnElement[int],
interest_rate_percent_column: KeyedColumnElement[int],
user_balance_column: KeyedColumnElement[int],
penalty_threshold_column: KeyedColumnElement[int],
penalty_multiplier_percent_column: KeyedColumnElement[int],
joint_user_shares_column: KeyedColumnElement[int],
joint_user_count_column: KeyedColumnElement[int],
use_cache: bool = True,
until_time: BindParameter[datetime] | None = None,
until_transaction: Transaction | None = None,
until_inclusive: bool = True,
cte_name: str = "product_price_cte",
trx_subset_name: str = "product_price_trx_subset",
):
# TODO: This can get quite expensive real quick, so we should do some caching of the
# product prices somehow.
expression = (
select(
cast(
func.ceil(
# Base price
(
cast(
column("price") * product_count_column * joint_user_shares_column,
Float,
)
/ joint_user_count_column
)
# Interest
+ (
cast(
column("price") * product_count_column * joint_user_shares_column,
Float,
)
/ joint_user_count_column
* cast(interest_rate_percent_column - const(100), Float)
/ const(100.0)
)
# Penalty
+ (
(
cast(
column("price") * product_count_column * joint_user_shares_column,
Float,
)
/ joint_user_count_column
)
* cast(penalty_multiplier_percent_column - const(100), Float)
/ const(100.0)
* cast(user_balance_column < penalty_threshold_column, Integer)
)
),
Integer,
)
)
.select_from(
_product_price_query(
product_id_column,
use_cache=use_cache,
until_time=until_time,
until_transaction=until_transaction,
until_inclusive=until_inclusive,
cte_name=cte_name,
trx_subset_name=trx_subset_name,
)
)
.order_by(column("i").desc())
.limit(CONST_ONE)
.scalar_subquery()
)
return expression
def _user_balance_query(
user_id: BindParameter[int] | int,
use_cache: bool = True,
until_time: BindParameter[datetime] | None = None,
until_transaction: Transaction | None = None,
until_inclusive: bool = True,
until: BindParameter[datetime] | BindParameter[None] | datetime | None = None,
until_including: BindParameter[bool] | bool = True,
cte_name: str = "rec_cte",
trx_subset_name: str = "trx_subset",
) -> CTE:
"""
The inner query for calculating the user's balance.
@@ -265,44 +47,30 @@ def _user_balance_query(
if isinstance(user_id, int):
user_id = BindParameter("user_id", value=user_id)
if isinstance(until, datetime):
until = BindParameter("until", value=until, type_=datetime)
if isinstance(until_including, bool):
until_including = BindParameter("until_including", value=until_including, type_=bool)
initial_element = select(
CONST_ZERO.label("i"),
CONST_ZERO.label("time"),
CONST_NONE.label("transaction_id"),
CONST_ZERO.label("balance"),
const(DEFAULT_INTEREST_RATE_PERCENT).label("interest_rate_percent"),
const(DEFAULT_INTEREST_RATE_PERCENTAGE).label("interest_rate_percent"),
const(DEFAULT_PENALTY_THRESHOLD).label("penalty_threshold"),
const(DEFAULT_PENALTY_MULTIPLIER_PERCENT).label("penalty_multiplier_percent"),
const(DEFAULT_PENALTY_MULTIPLIER_PERCENTAGE).label("penalty_multiplier_percent"),
)
recursive_cte = initial_element.cte(name=cte_name, recursive=True)
trx_subset_subset = (
_non_joint_transaction_query(
user_id=user_id,
use_cache=use_cache,
until_time=until_time,
until_transaction=until_transaction,
until_inclusive=until_inclusive,
)
.union_all(
_joint_transaction_query(
user_id=user_id,
use_cache=use_cache,
until_time=until_time,
until_transaction=until_transaction,
until_inclusive=until_inclusive,
)
)
.subquery(f"{trx_subset_name}_subset")
)
# Subset of transactions that we'll want to iterate over.
trx_subset = (
select(
func.row_number().over(order_by=Transaction.time.asc()).label("i"),
Transaction.id,
Transaction.amount,
Transaction.id,
Transaction.interest_rate_percent,
Transaction.penalty_multiplier_percent,
Transaction.penalty_threshold,
@@ -311,16 +79,44 @@ def _user_balance_query(
Transaction.time,
Transaction.transfer_user_id,
Transaction.type_,
trx_subset_subset.c.user_shares,
trx_subset_subset.c.user_count,
)
.select_from(trx_subset_subset)
.join(
Transaction,
onclause=Transaction.id == trx_subset_subset.c.id,
.where(
or_(
and_(
Transaction.user_id == user_id,
Transaction.type_.in_(
[
TransactionType.ADD_PRODUCT.as_literal_column(),
TransactionType.ADJUST_BALANCE.as_literal_column(),
TransactionType.BUY_PRODUCT.as_literal_column(),
TransactionType.TRANSFER.as_literal_column(),
# TODO: join this with the JOINT transactions, and determine
# how much the current user paid for the product.
TransactionType.JOINT_BUY_PRODUCT.as_literal_column(),
]
),
),
and_(
Transaction.type_ == TransactionType.TRANSFER.as_literal_column(),
Transaction.transfer_user_id == user_id,
),
Transaction.type_.in_(
[
TransactionType.THROW_PRODUCT.as_literal_column(),
TransactionType.ADJUST_INTEREST.as_literal_column(),
TransactionType.ADJUST_PENALTY.as_literal_column(),
]
),
),
case(
(until_including, Transaction.time <= until),
else_=Transaction.time < until,
)
if until is not None
else CONST_TRUE,
)
.order_by(Transaction.time.asc())
.subquery(trx_subset_name)
.alias("trx_subset")
)
recursive_elements = (
@@ -343,43 +139,49 @@ def _user_balance_query(
(
trx_subset.c.type_ == TransactionType.BUY_PRODUCT.as_literal_column(),
recursive_cte.c.balance
- _product_cost_expression(
product_count_column=trx_subset.c.product_count,
product_id_column=trx_subset.c.product_id,
interest_rate_percent_column=recursive_cte.c.interest_rate_percent,
user_balance_column=recursive_cte.c.balance,
penalty_threshold_column=recursive_cte.c.penalty_threshold,
penalty_multiplier_percent_column=recursive_cte.c.penalty_multiplier_percent,
joint_user_shares_column=CONST_ONE,
joint_user_count_column=CONST_ONE,
use_cache=use_cache,
until_time=until_time,
until_transaction=until_transaction,
until_inclusive=until_inclusive,
cte_name=f"{cte_name}_price",
trx_subset_name=f"{trx_subset_name}_price",
).label("product_cost"),
),
# Joint transaction -> balance decreases proportionally
(
trx_subset.c.type_ == TransactionType.JOINT.as_literal_column(),
recursive_cte.c.balance
- _product_cost_expression(
product_count_column=trx_subset.c.product_count,
product_id_column=trx_subset.c.product_id,
interest_rate_percent_column=recursive_cte.c.interest_rate_percent,
user_balance_column=recursive_cte.c.balance,
penalty_threshold_column=recursive_cte.c.penalty_threshold,
penalty_multiplier_percent_column=recursive_cte.c.penalty_multiplier_percent,
joint_user_shares_column=trx_subset.c.user_shares,
joint_user_count_column=trx_subset.c.user_count,
use_cache=use_cache,
until_time=until_time,
until_transaction=until_transaction,
until_inclusive=until_inclusive,
cte_name=f"{cte_name}_joint_price",
trx_subset_name=f"{trx_subset_name}_joint_price",
).label("joint_product_cost"),
- (
trx_subset.c.product_count
# Price of a single product, accounted for penalties and interest.
* cast(
func.ceil(
# TODO: This can get quite expensive real quick, so we should do some caching of the
# product prices somehow.
# Base price
(
# FIXME: this always returns 0 for some reason...
select(cast(column("price"), Float))
.select_from(
_product_price_query(
trx_subset.c.product_id,
use_cache=use_cache,
until=trx_subset.c.time,
until_including=False,
cte_name="product_price_cte",
)
)
.order_by(column("i").desc())
.limit(CONST_ONE)
).scalar_subquery()
# TODO: should interest be applied before or after the penalty multiplier?
# at the moment of writing, after sound right, but maybe ask someone?
# Interest
* (cast(recursive_cte.c.interest_rate_percent, Float) / const(100))
# TODO: these should be added together, not multiplied, see specification
# Penalty
* case(
(
recursive_cte.c.balance < recursive_cte.c.penalty_threshold,
(
cast(recursive_cte.c.penalty_multiplier_percent, Float)
/ const(100)
),
),
else_=const(1.0),
)
),
Integer,
)
),
),
# Transfers money to self -> balance increases
(
@@ -398,7 +200,8 @@ def _user_balance_query(
recursive_cte.c.balance - trx_subset.c.amount,
),
# Throws a product -> if the user is considered to have bought it, balance increases
# TODO: # (
# TODO:
# (
# trx_subset.c.type_ == TransactionType.THROW_PRODUCT,
# recursive_cte.c.balance + trx_subset.c.amount,
# ),
@@ -452,16 +255,17 @@ class UserBalanceLogEntry:
Returns whether this exact transaction is penalized.
"""
raise NotImplementedError("is_penalized is not implemented yet.")
return False
# return self.transaction.type_ == TransactionType.BUY_PRODUCT and prev?
# TODO: add until datetime parameter
def user_balance_log(
sql_session: Session,
user: User,
use_cache: bool = True,
until_time: BindParameter[datetime] | datetime | None = None,
until_transaction: Transaction | None = None,
until_inclusive: bool = True,
until: Transaction | None = None,
) -> list[UserBalanceLogEntry]:
"""
Returns a log of the user's balance over time, including interest and penalty adjustments.
@@ -472,18 +276,13 @@ def user_balance_log(
if user.id is None:
raise ValueError("User must be persisted in the database.")
if not (until_time is None or until_transaction is None):
raise ValueError("Cannot filter by both until_time and until_transaction.")
if isinstance(until_time, datetime):
until_time = BindParameter("until_time", value=until_time)
if until is not None and until.id is None:
raise ValueError("'until' transaction must be persisted in the database.")
recursive_cte = _user_balance_query(
user.id,
use_cache=use_cache,
until_time=until_time,
until_transaction=until_transaction,
until_inclusive=until_inclusive,
until=until.time if until else None,
)
result = sql_session.execute(
@@ -520,13 +319,13 @@ def user_balance_log(
]
# TODO: add until datetime parameter
def user_balance(
sql_session: Session,
user: User,
use_cache: bool = True,
until_time: BindParameter[datetime] | datetime | None = None,
until_transaction: Transaction | None = None,
until_inclusive: bool = True,
until: Transaction | None = None,
) -> int:
"""
Calculates the balance of a user.
@@ -537,18 +336,10 @@ def user_balance(
if user.id is None:
raise ValueError("User must be persisted in the database.")
if not (until_time is None or until_transaction is None):
raise ValueError("Cannot filter by both until_time and until_transaction.")
if isinstance(until_time, datetime):
until_time = BindParameter("until_time", value=until_time)
recursive_cte = _user_balance_query(
user.id,
use_cache=use_cache,
until_time=until_time,
until_transaction=until_transaction,
until_inclusive=until_inclusive,
until=until.time if until else None,
)
result = sql_session.scalar(

View File

@@ -1,11 +1,4 @@
from datetime import datetime
from sqlalchemy import BindParameter, bindparam
from sqlalchemy.orm import Session
from dibbler.models import Product, Transaction, User
# NOTE: This absolutely needs a cache, else we can't stop recursing until we know all owners for all products...
# This absoulutely needs a cache, else we can't stop recursing until we know all owners for all products...
#
# Since we know that the non-owned products will not get renowned by the user by other means,
# we can just check for ownership on the products that have an ADD_PRODUCT transaction for the user.
@@ -15,34 +8,3 @@ from dibbler.models import Product, Transaction, User
# but we still need to check if the user passes out of ownership for the item, without needing to check past
# the cache time. Maybe we also need to store the queue number(s) per user/product combo in the cache? What if
# a user has products multiple places in the queue, interleaved with other users?
def user_products(
sql_session: Session,
user: User,
use_cache: bool = True,
until_time: BindParameter[datetime] | datetime | None = None,
until_transaction: Transaction | None = None,
until_inclusive: bool = True,
) -> list[tuple[Product, int]]:
"""
Returns the list of products owned by the user, along with how many of each product they own.
"""
if user.id is None:
raise ValueError("User must be persisted in the database.")
if not (until_time is None or until_transaction is None):
raise ValueError("Cannot filter by both until_time and until_transaction.")
if isinstance(until_time, datetime):
until_time = BindParameter("until_time", value=until_time)
if isinstance(until_transaction, Transaction):
if until_transaction.id is None:
raise ValueError("until_transaction must be persisted in the database.")
until_transaction_id = bindparam("until_transaction_id", value=until_transaction.id)
else:
until_transaction_id = None
raise NotImplementedError("Not implemented yet, needs caching system first.")

View File

@@ -0,0 +1 @@
# TODO: implement me

View File

@@ -1,4 +1,4 @@
# Dibbler economy spec v1
# Economics
This document provides an overview of how dibbler counts and calculates its running event log.
@@ -8,26 +8,20 @@ It is a sort of semi-formal specification for how dibbler's economy is intended
- All calculations involving money are done in whole numbers (integers). There are no fractional krs.
- All rounding is done by rounding up to the nearest integer, in favor of the system economy - not the users.
- All rounding is done as late as possible in calculations, to avoid rounding errors accumulating.
- The system allows negative stock counts, but acts a bit weirdly and potentially unfairly when that happens.
The system should generally warn you about this, and recommend recounting the stock whenever it happens.
- Throughout the document, the penalty multiplier and interest rate are expressed as percentages in int (e.g. `penalty_multiplier = 150` means the prices should be multiplied by `1.5`, and `interest_rate = 120` means the prices should be multiplied by `1.2`).
## Adding products - product stock and product price
This section covers what happens to the stock count and price of a product when a user adds more of that product to the system.
### Calculating the total value of products added
When a user adds a product, the resulting product price is averaged over the new products and the existing products. However, the new product price will become an integer. To avoid the economy going downwards, we round up the price after doing the averaging - i.e. in favor of the system, not the users.
### When the product count is `0` before adding.
When the product count is `0`, adding more of that product sets the product count to the amount added, and the product price will be set to the price of all products added divided by the number of products added, rounded up to the nearest integer.
```python
new_product_count: int = products_added
new_product_price: int = math.ceil(total_value_of_products_added / products_added)
new_product_count = products_added
new_product_price = math.ceil(total_value_of_products_added / products_added)
```
### When the product count is greater than `0` before adding.
@@ -35,8 +29,8 @@ new_product_price: int = math.ceil(total_value_of_products_added / products_adde
When the product count is greater than `0`, adding more of that product increases the product count by the amount added, and the product price will be recalculated as the total value of all existing products plus the total value of all newly added products, divided by the new total product count, rounded up to the nearest integer.
```python
new_product_count: int = product_count + products_added
new_product_price: int = math.ceil((product_price * product_count + total_value_of_new_products_added) / new_product_count)
new_product_count = product_count + products_added
new_product_price = math.ceil((total_value_of_existing_products + total_value_of_products_added) / new_product_count)
```
### When the product count is less than `0` before adding.
@@ -50,11 +44,11 @@ When the product count is less than `0`, adding more of that product increases t
> [!WARN]
> Note that this means that if you add products to a negative stock and the stock is still negative,
> the product price will be completely recalculated the next time someone adds the same product.
> There will also be a noticeable effect if the stock goes from negative to positive.
> There will also be a noticable effect if the stock goes from negative to positive.
```python
new_product_count: int = product_count + products_added
new_product_price: int = math.ceil(((product_price * max(product_count, 0)) + (total_value_of_new_products_added)) / new_product_count)
new_product_count = product_count + products_added
new_product_price = math.ceil(((product_price * math.max(product_count, 0)) + (total_value_of_products_added)) / new_product_count)
```
### A note about adding `0` items
@@ -69,7 +63,7 @@ If a user attempts to add `0` items of a product, the system will not change the
When the product count is positive and a user buys an amount less than or equal to the current stock count, the product stock count will be decreased by the amount bought.
```python
new_product_count: int = product_count - products_bought
new_product_count = product_count - products_bought
```
### When the product count is positive or `0` and you buy more than there are in stock
@@ -77,7 +71,7 @@ new_product_count: int = product_count - products_bought
When the product count is positive and a user buys an amount greater than the current stock count, the product stock count will be decreased by the amount bought, resulting in a negative stock count.
```python
new_product_count: int = product_count - products_bought
new_product_count = product_count - products_bought
```
> [!NOTE]
@@ -88,7 +82,7 @@ new_product_count: int = product_count - products_bought
When the product count is negative, buying more of that product will further decrease the product stock count.
```python
new_product_count: int = product_count - products_bought
new_product_count = product_count - products_bought
```
> [!NOTE]
@@ -109,10 +103,10 @@ If a user attempts to buy `0` items of a product, the system will not change the
We have had some issues with the economy going in the negative, most likely due to users throwing away products gone bad. When the economy goes negative, we end up in a situation where users have money but there aren't really any products to buy, because the users don't have the incentive to add products back into the system to gain more balance.
To readjust the economy over time, there is an interest rate that will increase the amount you pay for each product by a certain percentage (the interest rate). This percentage can be adjusted by administrators when they see that the economy needs fixing. By default, the interest rate is set to `100%` (i.e., you don't pay anything extra).
To readjust the economy over time, there is an interest rate that will increase the amount you pay for each product by a certain percentage (the interest rate). This percentage can be adjusted by administrators when they see that the economy needs fixing. By default, the interest rate is set to `0%`.
> [!NOTE]
> You can not go below `100%` interest rate.
> You can not go below `0%` interest rate.
### What is penalty, and why do we need it
@@ -133,9 +127,7 @@ You gain balance equal to the total value of the products you add.
Note that this might be separate from the per-product cost of the products after you add them, due to rounding and price recalculation.
```python
new_user_balance: int = user_balance + total_value_of_products_added
assert total_value_of_new_products_added >= product_price * products_added
new_user_balance = user_balance + total_value_of_products_added
```
### When your existing balance is below the penalty threshold
@@ -150,7 +142,7 @@ This case is the same as above.
You pay the normal product price for the products you buy, plus any interest.
```python
new_user_balance: int = user_balance - math.ceil(products_bought * product_price * (interest_rate / 100))
new_user_balance = user_balance - (products_bought * product_price * (1 + interest_rate))
```
Note that the system performs a transaction for every product kind, so if you buy multiple different products in one go, the rounding is done per product kind.
@@ -162,67 +154,34 @@ You pay the penalized product price for the products you buy, plus any interest.
The interest and penalty are calculated separately before they are added together, *not* multiplied together.
```python
base_cost: float = product_price * products_bought
penalty: float = (base_cost * (penalty_multiplier / 100)) - base_cost
interest: float = (base_cost * (interest_rate / 100)) - base_cost
new_user_balance: int = user_balance - math.ceil(base_cost + penalty + interest)
penalty = ((product_price * penalty_multiplier) - product_price)
interest = (product_price * interest_rate)
new_user_balance = user_balance - (products_bought * (product_price + penalty + interest))
```
### When your balance is above the penalty threshold before buying, but the purchase pushes you below the threshold
When your balance is above the penalty threshold before buying, but the purchase pushes you below the threshold, the system not apply any penalty for the purchase. The entire purchase is done at the normal product price plus any interest.
TODO:
```python
new_user_balance: int = user_balance - math.ceil(products_bought * product_price * (interest_rate / 100))
```
> [!NOTE]
> In the case where you are performing multiple transactions at once, the system should try its best to order the purchases in a way that minimizes the amount of penalties you need to pay.
### Joint purchases, when all users are above the penalty threshold and stays above the threshold
When making joint purchases (multiple users buying products together), and all users are above the penalty threshold before and after the purchase, the total cost (including interest) will be split equally between all users. The price will be rounded up for each user after splitting the bill.
TODO: how does rounding work here, does one user pay more than the other?
```python
total_cost: float = product_price * products_bought * (interest_rate / 100)
cost_per_user: float = total_cost / number_of_users
new_user_balance = user_balance - math.ceil(cost_per_user)
```
TODO: ordering the purchases in favor of the user.
### Joint purchases where a user appears more than one time
When performing joint purchases (multiple users
When a user appears more than once in a joint purchase (e.g. two people buying together, but one of them is buying twice as much as the other), the system will the amount of times a user appears in the purchase as a multiplier for the base price. You can think of it as if the user is having shares in the joint purchase.
```python
base_cost_for_user: float = product_price * products_bought * user_shares / total_user_shares
added_interest: float = base_cost_for_user * ((interest_rate - 100) / 100)
new_user_balance: int = user_balance - math.ceil(base_cost_for_user + added_interest)
```
### Joint purchases when one or more users are below the penalty threshold
The cost for each user will be calculated as usual, but for the users who are below the penalty threshold, the penalty will also be calculated and added to this user's cost. The penalty is calculated based on the share of the total purchase that this user is responsible for.
```python
base_cost_for_user: float = product_price * products_bought * user_shares / total_user_shares
added_interest: float = base_cost_for_user * ((interest_rate - 100) / 100)
penalty: float = base_cost_for_user * ((penalty_multiplier - 100) / 100)
new_user_balance: int = user_balance - math.ceil(base_cost_for_user + added_interest + penalty)
```
TODO
### Joint purchases when one or more users will end up below the penalty threshold after the purchase
Just as the single-user case, if a user who is part of a joint purchase is above the penalty threshold before the purchase, but will end up below the threshold after the purchase, no penalty will be applied to that user for this purchase. The entire cost (including interest) will be split equally between all users.
```python
base_cost_for_user: float = product_price * products_bought * user_shares / total_user_shares
added_interest: float = base_cost_for_user * ((interest_rate - 100) / 100)
new_user_balance: int = user_balance - math.ceil(base_cost_for_user + added_interest)
```
> [!NOTE]
> In the case where you (and others) are performing multiple transactions at once, the system should try its best to order the purchases in a way that minimizes the amount of penalties you need to pay.
TODO
## Who owns a product
@@ -238,26 +197,12 @@ Upon throwing away products (not manual adjustment), the system will pull money
## Other actions
### Transfers
Transfers
You can transfer money from one user to another. The amount transferred will be deducted from the sender's balance and added to the receiver's balance without any interest or penalty applied.
Note about self-transfers
```python
new_sender_balance: int = sender_balance - amount_transferred
new_receiver_balance: int = receiver_balance + amount_transferred
```
> [!NOTE]
> Transfers from one user to itself are not allowed.
### Balance adjustments
You can manually adjust a user's balance. This action will not have any multipliers of any kind applied, and will simply add or subtract the specified amount from the user's balance.
```python
new_user_balance: int = user_balance + adjustment_amount
```
Balance adjustments
## Updating the economy specification
All transactions in the database are tagged with the economy specification version they were created under. If you are to update this document with changes to how the economy works, and change the software accordingly, you will want to keep the old logic around and bump the version number. This way, the old event log is still valid, and will be aggregated using the old logic, while new transactions will user the logic applicable to the version they were created under.
Keep old logic, database rows tagged with spec version.

View File

@@ -17,31 +17,21 @@
pkgs = nixpkgs.legacyPackages.${system};
in f system pkgs);
in {
apps = let
mkApp = program: description: {
type = "app";
program = toString program;
meta = {
inherit description;
};
packages = forAllSystems (system: pkgs: {
default = self.packages.${system}.dibbler;
dibbler = pkgs.callPackage ./nix/dibbler.nix {
python3Packages = pkgs.python312Packages;
};
mkVm = name: mkApp "${self.nixosConfigurations.${name}.config.system.build.vm}/bin/run-nixos-vm";
in forAllSystems (system: pkgs: {
skrot = self.nixosConfigurations.skrot.config.system.build.sdImage;
});
apps = forAllSystems (system: pkgs: {
default = self.apps.${system}.dibbler;
dibbler = flake-utils.lib.mkApp {
drv = self.packages.${system}.dibbler;
};
vm = mkVm "vm" "Start a NixOS VM with dibbler installed in kiosk-mode";
vm-non-kiosk = mkVm "vm-non-kiosk" "Start a NixOS VM with dibbler installed in nonkiosk-mode";
});
nixosModules.default = import ./nix/module.nix;
nixosConfigurations = {
vm = import ./nix/nixos-configurations/vm.nix { inherit self nixpkgs; };
vm-non-kiosk = import ./nix/nixos-configurations/vm-non-kiosk.nix { inherit self nixpkgs; };
};
overlays = {
default = self.overlays.dibbler;
dibbler = final: prev: {
@@ -56,11 +46,20 @@
};
});
packages = forAllSystems (system: pkgs: {
default = self.packages.${system}.dibbler;
dibbler = pkgs.callPackage ./nix/package.nix {
python3Packages = pkgs.python312Packages;
# Note: using the module requires that you have applied the overlay first
nixosModules.default = import ./nix/module.nix;
nixosConfigurations.skrot = nixpkgs.lib.nixosSystem (rec {
system = "aarch64-linux";
pkgs = import nixpkgs {
inherit system;
overlays = [ self.overlays.dibbler ];
};
modules = [
(nixpkgs + "/nixos/modules/installer/sd-card/sd-image-aarch64.nix")
self.nixosModules.default
./nix/skrott.nix
];
});
};
}

32
nix/dibbler.nix Normal file
View File

@@ -0,0 +1,32 @@
{ lib
, python3Packages
, fetchFromGitHub
}:
python3Packages.buildPythonApplication {
pname = "dibbler";
version = "unstable";
src = lib.cleanSource ../.;
format = "pyproject";
# brother-ql is breaky breaky
# https://github.com/NixOS/nixpkgs/issues/285234
dontCheckRuntimeDeps = true;
pythonImportsCheck = [];
doCheck = true;
nativeCheckInputs = with python3Packages; [
pytest
pytestCheckHook
];
nativeBuildInputs = with python3Packages; [ setuptools ];
propagatedBuildInputs = with python3Packages; [
brother-ql
matplotlib
psycopg2-binary
python-barcode
sqlalchemy
];
}

View File

@@ -8,45 +8,6 @@ in {
package = lib.mkPackageOption pkgs "dibbler" { };
screenPackage = lib.mkPackageOption pkgs "screen" { };
createLocalDatabase = lib.mkEnableOption "" // {
description = ''
Whether to set up a local postgres database automatically.
::: {.note}
You must set up postgres manually before enabling this option.
:::
'';
};
kioskMode = lib.mkEnableOption "" // {
description = ''
Whether to let dibbler take over the entire machine.
This will restrict the machine to a single TTY and make the program unquittable.
You can still get access to PTYs via SSH and similar, if enabled.
'';
};
limitScreenHeight = lib.mkOption {
type = with lib.types; nullOr ints.unsigned;
default = null;
example = 42;
description = ''
If set, limits the height of the screen dibbler uses to the given number of lines.
'';
};
limitScreenWidth = lib.mkOption {
type = with lib.types; nullOr ints.unsigned;
default = null;
example = 80;
description = ''
If set, limits the width of the screen dibbler uses to the given number of columns.
'';
};
settings = lib.mkOption {
description = "Configuration for dibbler";
default = { };
@@ -56,128 +17,61 @@ in {
};
};
config = lib.mkIf cfg.enable (lib.mkMerge [
{
services.dibbler.settings = lib.pipe ../example-config.ini [
builtins.readFile
builtins.fromTOML
(lib.mapAttrsRecursive (_: lib.mkDefault))
];
}
{
environment.systemPackages = [ cfg.package ];
config = let
screen = "${pkgs.screen}/bin/screen";
in lib.mkIf cfg.enable {
services.dibbler.settings = lib.pipe ../example-config.ini [
builtins.readFile
builtins.fromTOML
(lib.mapAttrsRecursive (_: lib.mkDefault))
];
environment.etc."dibbler/dibbler.conf".source = format.generate "dibbler.conf" cfg.settings;
boot = {
consoleLogLevel = 0;
enableContainers = false;
loader.grub.enable = false;
};
users = {
users.dibbler = {
group = "dibbler";
isNormalUser = true;
};
groups.dibbler = { };
};
services.dibbler.settings.database.url = lib.mkIf cfg.createLocalDatabase "postgresql://dibbler?host=/run/postgresql";
services.postgresql = lib.mkIf cfg.createLocalDatabase {
ensureDatabases = [ "dibbler" ];
ensureUsers = [{
name = "dibbler";
ensureDBOwnership = true;
ensureClauses.login = true;
}];
};
systemd.services.dibbler-setup-database = lib.mkIf cfg.createLocalDatabase {
description = "Dibbler database setup";
wantedBy = [ "default.target" ];
after = [ "postgresql.service" ];
unitConfig = {
ConditionPathExists = "!/var/lib/dibbler/.db-setup-done";
};
serviceConfig = {
Type = "oneshot";
ExecStart = "${lib.getExe cfg.package} --config /etc/dibbler/dibbler.conf create-db";
ExecStartPost = "${lib.getExe' pkgs.coreutils "touch"} /var/lib/dibbler/.db-setup-done";
StateDirectory = "dibbler";
User = "dibbler";
Group = "dibbler";
};
};
}
(lib.mkIf cfg.kioskMode {
boot.kernelParams = [
"console=tty1"
];
users.users.dibbler = {
users = {
groups.dibbler = { };
users.dibbler = {
group = "dibbler";
extraGroups = [ "lp" ];
shell = (pkgs.writeShellScriptBin "login-shell" "${lib.getExe cfg.screenPackage} -x dibbler") // {
shellPath = "/bin/login-shell";
};
isNormalUser = true;
shell = (pkgs.writeShellScriptBin "login-shell" "${screen} -x dibbler") // {shellPath = "/bin/login-shell";};
};
};
services.dibbler.settings.general = {
quit_allowed = false;
stop_allowed = false;
systemd.services.screen-daemon = {
description = "Dibbler service screen";
wantedBy = [ "default.target" ];
serviceConfig = {
ExecStartPre = "-${screen} -X -S dibbler kill";
ExecStart = let
config = format.generate "dibbler-config.ini" cfg.settings;
in "${screen} -dmS dibbler -O -l ${cfg.package}/bin/dibbler --config ${config} loop";
ExecStartPost = "${screen} -X -S dibbler width 42 80";
User = "dibbler";
Group = "dibbler";
Type = "forking";
RemainAfterExit = false;
Restart = "always";
RestartSec = "5s";
SuccessExitStatus = 1;
};
};
systemd.services.dibbler-screen-session = {
description = "Dibbler Screen Session";
wantedBy = [
"default.target"
];
after = if cfg.createLocalDatabase then [
"postgresql.service"
"dibbler-setup-database.service"
] else [
"network.target"
];
serviceConfig = {
Type = "forking";
RemainAfterExit = false;
Restart = "always";
RestartSec = "5s";
SuccessExitStatus = 1;
# https://github.com/NixOS/nixpkgs/issues/84105
boot.kernelParams = [
"console=ttyUSB0,9600"
"console=tty1"
];
systemd.services."serial-getty@ttyUSB0" = {
enable = true;
wantedBy = [ "getty.target" ]; # to start at boot
serviceConfig.Restart = "always"; # restart when session is closed
};
User = "dibbler";
Group = "dibbler";
ExecStartPre = "-${lib.getExe cfg.screenPackage} -X -S dibbler kill";
ExecStart = let
screenArgs = lib.escapeShellArgs [
# -dm creates the screen in detached mode without accessing it
"-dm"
# Session name
"-S"
"dibbler"
# Set optimal output mode instead of VT100 emulation
"-O"
# Enable login mode, updates utmp entries
"-l"
];
dibblerArgs = lib.cli.toCommandLineShellGNU { } {
config = "/etc/dibbler/dibbler.conf";
};
in "${lib.getExe cfg.screenPackage} ${screenArgs} ${lib.getExe cfg.package} ${dibblerArgs} loop";
ExecStartPost =
lib.optionals (cfg.limitScreenWidth != null) [
"${lib.getExe cfg.screenPackage} -X -S dibbler width ${toString cfg.limitScreenWidth}"
]
++ lib.optionals (cfg.limitScreenHeight != null) [
"${lib.getExe cfg.screenPackage} -X -S dibbler height ${toString cfg.limitScreenHeight}"
];
};
};
services.getty.autologinUser = "dibbler";
})
]);
services.getty.autologinUser = lib.mkForce "dibbler";
};
}

View File

@@ -1,54 +0,0 @@
{ self, nixpkgs, ... }:
nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
pkgs = import nixpkgs {
system = "x86_64-linux";
overlays = [
self.overlays.dibbler
];
};
modules = [
"${nixpkgs}/nixos/modules/virtualisation/qemu-vm.nix"
"${nixpkgs}/nixos/tests/common/user-account.nix"
self.nixosModules.default
({ config, ... }: {
system.stateVersion = config.system.nixos.release;
virtualisation.graphics = false;
users.motd = ''
=================================
Welcome to the dibbler non-kiosk vm!
Try running:
${config.services.dibbler.package.meta.mainProgram} loop
Password for dibbler is 'dibbler'
To exit, press Ctrl+A, then X
=================================
'';
users.users.dibbler = {
isNormalUser = true;
password = "dibbler";
extraGroups = [ "wheel" ];
};
services.getty.autologinUser = "dibbler";
programs.vim = {
enable = true;
defaultEditor = true;
};
services.postgresql.enable = true;
services.dibbler = {
enable = true;
createLocalDatabase = true;
};
})
];
}

View File

@@ -1,29 +0,0 @@
{ self, nixpkgs, ... }:
nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
pkgs = import nixpkgs {
system = "x86_64-linux";
overlays = [
self.overlays.default
];
};
modules = [
"${nixpkgs}/nixos/modules/virtualisation/qemu-vm.nix"
"${nixpkgs}/nixos/tests/common/user-account.nix"
self.nixosModules.default
({ config, ... }: {
system.stateVersion = config.system.nixos.release;
virtualisation.graphics = false;
services.postgresql.enable = true;
services.dibbler = {
enable = true;
createLocalDatabase = true;
kioskMode = true;
};
})
];
}

View File

@@ -1,53 +0,0 @@
{ lib
, python3Packages
, makeWrapper
, less
}:
let
pyproject = builtins.fromTOML (builtins.readFile ../pyproject.toml);
in
python3Packages.buildPythonApplication {
pname = pyproject.project.name;
version = pyproject.project.version;
src = lib.cleanSource ../.;
format = "pyproject";
# brother-ql is breaky breaky
# https://github.com/NixOS/nixpkgs/issues/285234
dontCheckRuntimeDeps = true;
nativeBuildInputs = with python3Packages; [
setuptools
makeWrapper
];
propagatedBuildInputs = with python3Packages; [
brother-ql
matplotlib
psycopg2-binary
python-barcode
sqlalchemy
];
pythonImportsCheck = [];
doCheck = true;
nativeCheckInputs = with python3Packages; [
pytest
pytestCheckHook
sqlparse
pytest-html
pytest-cov
pytest-benchmark
];
postInstall = ''
wrapProgram $out/bin/dibbler \
--prefix PATH : "${lib.makeBinPath [ less ]}"
'';
meta = {
description = "The little kiosk that could";
mainProgram = "dibbler";
};
}

View File

@@ -20,8 +20,6 @@ mkShell {
pytest
pytest-cov
pytest-html
pytest-benchmark
pygal
]))
];
}

27
nix/skrott.nix Normal file
View File

@@ -0,0 +1,27 @@
{...}: {
system.stateVersion = "25.05";
services.dibbler.enable = true;
networking = {
hostName = "skrot";
domain = "pvv.ntnu.no";
nameservers = [ "129.241.0.200" "129.241.0.201" ];
defaultGateway = "129.241.210.129";
interfaces.eth0 = {
useDHCP = false;
ipv4.addresses = [{
address = "129.241.210.235";
prefixLength = 25;
}];
};
};
# services.resolved.enable = true;
# systemd.network.enable = true;
# systemd.network.networks."30-network" = {
# matchConfig.Name = "*";
# DHCP = "no";
# address = [ "129.241.210.235/25" ];
# gateway = [ "129.241.210.129" ];
# };
}

View File

@@ -4,10 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "dibbler"
version = "1.0.0"
authors = [
{ name = "Programvareverkstedet", email = "projects@pvv.ntnu.no" }
]
authors = []
description = "EDB-system for PVV"
readme = "README.md"
requires-python = ">=3.11"
@@ -21,6 +18,7 @@ dependencies = [
"psycopg2-binary >= 2.8, <2.10",
"python-barcode",
]
dynamic = ["version"]
[dependency-groups]
test = [
@@ -29,7 +27,6 @@ test = [
"coverage-badge>=1.1.2",
"pytest-html>=4.1.1",
"sqlparse>=0.5.4",
"pytest-benchmark[histogram]>=5.2.3",
]
[tool.setuptools.packages.find]
@@ -43,22 +40,3 @@ line-length = 100
[tool.ruff]
line-length = 100
[tool.pytest.ini_options]
addopts = [
"--cov=dibbler.lib",
"--cov=dibbler.models",
"--cov=dibbler.queries",
"--cov-report=html",
"--cov-branch",
"--self-contained-html",
"--html=./test-report/index.html",
"--benchmark-skip",
"--benchmark-autosave",
"--benchmark-save=default",
"--benchmark-verbose",
"--benchmark-storage=benchmark",
"--benchmark-histogram=benchmark/histogram",
]

View File

@@ -1,11 +0,0 @@
TRANSACTION_GENERATOR_EXCEPTION_LIMIT = 15
"""
The random transaction generator uses a set seed to generate transactions.
However, not all transactions are valid in all contexts. We catch illegal
generated transactions with a try/except, and retry until we generate a valid
one. However, if we exceed this limit, something is likely wrong with the generator
instead, due to the unlikely high number of exceptions.
"""
BENCHMARK_ITERATIONS = 5
BENCHMARK_ROUNDS = 3

View File

@@ -1,304 +0,0 @@
import random
from datetime import datetime, timedelta
from sqlalchemy.orm import Session
from dibbler.models import Product, Transaction, TransactionType, User
from dibbler.queries import joint_buy_product
from tests.benchmark.benchmark_settings import TRANSACTION_GENERATOR_EXCEPTION_LIMIT
def insert_users_and_products(
sql_session: Session, user_count: int = 10, product_count: int = 10
) -> tuple[list[User], list[Product]]:
users = []
for i in range(user_count):
user = User(f"User{i + 1}")
sql_session.add(user)
users.append(user)
sql_session.commit()
products = []
for i in range(product_count):
barcode = str(1000000000000 + i)
product = Product(barcode, f"Product{i + 1}")
sql_session.add(product)
products.append(product)
sql_session.commit()
return users, products
def generate_random_transactions(
sql_session: Session,
n: int,
seed: int = 42,
transaction_type_filter: list[TransactionType] | None = None,
distribution: dict[TransactionType, float] | None = None,
cache_every_n: int | None = None,
) -> list[Transaction]:
random.seed(seed)
if transaction_type_filter is None:
transaction_type_filter = list(TransactionType)
if TransactionType.JOINT_BUY_PRODUCT in transaction_type_filter:
transaction_type_filter.remove(TransactionType.JOINT_BUY_PRODUCT)
# TODO: implement me
if TransactionType.THROW_PRODUCT in transaction_type_filter:
transaction_type_filter.remove(TransactionType.THROW_PRODUCT)
if distribution is None:
distribution = {t: 1 / len(transaction_type_filter) for t in transaction_type_filter}
transaction_types = list(distribution.keys())
weights = list(distribution.values())
transactions: list[Transaction] = []
last_time = datetime(2023, 1, 1, 0, 0, 0)
for _ in range(n):
transaction_type = random.choices(transaction_types, weights=weights, k=1)[0]
generator = RANDOM_GENERATORS[transaction_type]
transaction_or_transactions = generator(sql_session, last_time)
if isinstance(transaction_or_transactions, list):
transactions.extend(transaction_or_transactions)
last_time = max(t.time for t in transaction_or_transactions)
else:
transactions.append(transaction_or_transactions)
last_time = transaction_or_transactions.time
return transactions
def random_add_product_transaction(sql_session: Session, last_time: datetime) -> Transaction:
i = 0
while True:
i += 1
user = random.choice(sql_session.query(User).all())
product = random.choice(sql_session.query(Product).all())
product_count = random.randint(1, 10)
product_price = random.randint(15, 45)
amount = product_count * product_price + random.randint(-7, 0)
new_datetime = last_time + timedelta(minutes=random.randint(1, 60))
try:
transaction = Transaction.add_product(
amount,
user.id,
product.id,
product_price,
product_count,
time=new_datetime,
)
except Exception:
if i > TRANSACTION_GENERATOR_EXCEPTION_LIMIT:
raise RuntimeError(
"Too many failed attempts to create a valid transaction, consider changing the seed"
)
continue
return transaction
def random_adjust_balance_transaction(sql_session: Session, last_time: datetime) -> Transaction:
i = 0
while True:
i += 1
user = random.choice(sql_session.query(User).all())
amount = random.randint(-50, 100)
if amount == 0:
amount = 1
new_datetime = last_time + timedelta(minutes=random.randint(1, 60))
try:
transaction = Transaction.adjust_balance(
amount,
user.id,
time=new_datetime,
)
except Exception:
if i > TRANSACTION_GENERATOR_EXCEPTION_LIMIT:
raise RuntimeError(
"Too many failed attempts to create a valid transaction, consider changing the seed"
)
continue
return transaction
def random_adjust_interest_transaction(sql_session: Session, last_time: datetime) -> Transaction:
i = 0
while True:
i += 1
user = random.choice(sql_session.query(User).all())
amount = random.randint(100, 105)
new_datetime = last_time + timedelta(minutes=random.randint(1, 60))
try:
transaction = Transaction.adjust_interest(
amount,
user.id,
time=new_datetime,
)
except Exception:
if i > TRANSACTION_GENERATOR_EXCEPTION_LIMIT:
raise RuntimeError(
"Too many failed attempts to create a valid transaction, consider changing the seed"
)
continue
return transaction
def random_adjust_penalty_transaction(sql_session: Session, last_time: datetime) -> Transaction:
i = 0
while True:
i += 1
user = random.choice(sql_session.query(User).all())
penalty_multiplier_percent = random.randint(100, 200)
penalty_threshold = random.randint( -150, -50)
new_datetime = last_time + timedelta(minutes=random.randint(1, 60))
try:
transaction = Transaction.adjust_penalty(
penalty_multiplier_percent,
penalty_threshold,
user.id,
time=new_datetime,
)
except Exception:
if i > TRANSACTION_GENERATOR_EXCEPTION_LIMIT:
raise RuntimeError(
"Too many failed attempts to create a valid transaction, consider changing the seed"
)
continue
return transaction
def random_adjust_stock_transaction(sql_session: Session, last_time: datetime) -> Transaction:
i = 0
while True:
i += 1
user = random.choice(sql_session.query(User).all())
product = random.choice(sql_session.query(Product).all())
stock_change = random.randint(-5, 6)
if stock_change == 0:
stock_change = 1
new_datetime = last_time + timedelta(minutes=random.randint(1, 60))
try:
transaction = Transaction.adjust_stock(
user_id=user.id,
product_id=product.id,
product_count=stock_change,
time=new_datetime,
)
except Exception:
if i > TRANSACTION_GENERATOR_EXCEPTION_LIMIT:
raise RuntimeError(
"Too many failed attempts to create a valid transaction, consider changing the seed"
)
continue
return transaction
def random_buy_product_transaction(sql_session: Session, last_time: datetime) -> Transaction:
i = 0
while True:
i += 1
user = random.choice(sql_session.query(User).all())
product = random.choice(sql_session.query(Product).all())
product_count = random.randint(1, 5)
new_datetime = last_time + timedelta(minutes=random.randint(1, 60))
try:
transaction = Transaction.buy_product(
user_id=user.id,
product_id=product.id,
product_count=product_count,
time=new_datetime,
)
except Exception:
if i > TRANSACTION_GENERATOR_EXCEPTION_LIMIT:
raise RuntimeError(
"Too many failed attempts to create a valid transaction, consider changing the seed"
)
continue
return transaction
def random_joint_transaction(sql_session: Session, last_time: datetime) -> list[Transaction]:
i = 0
while True:
i += 1
user_count = random.randint(2, 4)
users = random.sample(sql_session.query(User).all(), k=user_count)
product = random.choice(sql_session.query(Product).all())
product_count = random.randint(1, 5)
new_datetime = last_time + timedelta(minutes=random.randint(1, 60))
try:
transactions = joint_buy_product(
sql_session,
product=product,
product_count=product_count,
instigator=users[0],
users=users,
time=new_datetime,
)
except Exception:
if i > TRANSACTION_GENERATOR_EXCEPTION_LIMIT:
raise RuntimeError(
"Too many failed attempts to create a valid transaction, consider changing the seed"
)
continue
return transactions
def random_transfer_transaction(sql_session: Session, last_time: datetime) -> Transaction:
i = 0
while True:
i += 1
sender, receiver = random.sample(sql_session.query(User).all(), k=2)
amount = random.randint(1, 50)
new_datetime = last_time + timedelta(minutes=random.randint(1, 60))
try:
transaction = Transaction.transfer(
amount,
sender.id,
receiver.id,
time=new_datetime,
)
except Exception:
if i > TRANSACTION_GENERATOR_EXCEPTION_LIMIT:
raise RuntimeError(
"Too many failed attempts to create a valid transaction, consider changing the seed"
)
continue
return transaction
def random_throw_product_transaction(sql_session: Session, last_time: datetime) -> Transaction:
i = 0
while True:
i += 1
user = random.choice(sql_session.query(User).all())
product = random.choice(sql_session.query(Product).all())
product_count = random.randint(1, 5)
new_datetime = last_time + timedelta(minutes=random.randint(1, 60))
try:
transaction = Transaction.throw_product(
user_id=user.id,
product_id=product.id,
product_count=product_count,
time=new_datetime,
)
except Exception:
if i > TRANSACTION_GENERATOR_EXCEPTION_LIMIT:
raise RuntimeError(
"Too many failed attempts to create a valid transaction, consider changing the seed"
)
continue
return transaction
RANDOM_GENERATORS = {
TransactionType.ADD_PRODUCT: random_add_product_transaction,
TransactionType.ADJUST_BALANCE: random_adjust_balance_transaction,
TransactionType.ADJUST_INTEREST: random_adjust_interest_transaction,
TransactionType.ADJUST_PENALTY: random_adjust_penalty_transaction,
TransactionType.ADJUST_STOCK: random_adjust_stock_transaction,
TransactionType.BUY_PRODUCT: random_buy_product_transaction,
TransactionType.JOINT: random_joint_transaction,
TransactionType.TRANSFER: random_transfer_transaction,
TransactionType.THROW_PRODUCT: random_throw_product_transaction,
}

View File

@@ -1,54 +0,0 @@
import pytest
from sqlalchemy.orm import Session
from dibbler.models import Product, TransactionType
from dibbler.queries import product_owners
from tests.benchmark.benchmark_settings import BENCHMARK_ITERATIONS, BENCHMARK_ROUNDS
from tests.benchmark.helpers import generate_random_transactions, insert_users_and_products
@pytest.mark.benchmark(group="product_owners")
@pytest.mark.parametrize(
"transaction_count",
[
100,
500,
1000,
2000,
5000,
10000,
],
)
def test_benchmark_product_owners(
benchmark,
sql_session: Session,
transaction_count: int,
):
_users, products = insert_users_and_products(sql_session)
transactions = generate_random_transactions(
sql_session,
transaction_count,
transaction_type_filter=[
TransactionType.ADD_PRODUCT,
TransactionType.ADJUST_STOCK,
TransactionType.BUY_PRODUCT,
TransactionType.JOINT,
TransactionType.THROW_PRODUCT,
],
)
sql_session.add_all(transactions)
sql_session.commit()
benchmark.pedantic(
query_all_product_owners,
args=(sql_session, products),
iterations=BENCHMARK_ITERATIONS,
rounds=BENCHMARK_ROUNDS,
)
def query_all_product_owners(sql_session: Session, products: list[Product]) -> None:
for product in products:
product_owners(sql_session, product, use_cache=False)

View File

@@ -1,92 +0,0 @@
import pytest
from sqlalchemy.orm import Session
from dibbler.models import Product, TransactionType
from dibbler.queries import product_price, update_cache
from tests.benchmark.benchmark_settings import BENCHMARK_ITERATIONS, BENCHMARK_ROUNDS
from tests.benchmark.helpers import generate_random_transactions, insert_users_and_products
# @pytest.mark.benchmark(group="product_price")
# @pytest.mark.parametrize(
# "transaction_count",
# [
# 100,
# 500,
# 1000,
# 2000,
# 5000,
# 10000,
# ],
# )
# def test_benchmark_product_price(benchmark, sql_session: Session, transaction_count):
# _users, products = insert_users_and_products(sql_session)
# transactions = generate_random_transactions(
# sql_session,
# transaction_count,
# transaction_type_filter=[
# TransactionType.ADD_PRODUCT,
# TransactionType.ADJUST_STOCK,
# TransactionType.BUY_PRODUCT,
# TransactionType.JOINT,
# TransactionType.THROW_PRODUCT,
# ],
# )
# sql_session.add_all(transactions)
# sql_session.commit()
# benchmark.pedantic(
# query_all_products_price,
# args=(sql_session, products),
# iterations=BENCHMARK_ITERATIONS,
# rounds=BENCHMARK_ROUNDS,
# )
@pytest.mark.benchmark(group="product_price")
@pytest.mark.parametrize(
"transaction_count",
[
1000,
2000,
5000,
10000,
],
)
def test_benchmark_product_price_cache_every_500(
benchmark,
sql_session: Session,
transaction_count: int,
):
users, _products = insert_users_and_products(sql_session)
transactions = generate_random_transactions(
sql_session,
transaction_count,
transaction_type_filter=[
TransactionType.ADD_PRODUCT,
TransactionType.ADJUST_STOCK,
TransactionType.BUY_PRODUCT,
TransactionType.JOINT,
TransactionType.THROW_PRODUCT,
],
)
for i in range(0, len(transactions), 500):
update_cache(sql_session)
sql_session.add_all(transactions[i : i + 500])
sql_session.commit()
benchmark.pedantic(
query_all_products_price,
args=(sql_session, users),
iterations=BENCHMARK_ITERATIONS,
rounds=BENCHMARK_ROUNDS,
)
def query_all_products_price(sql_session: Session, products: list[Product]) -> None:
for product in products:
product_price(sql_session, product, use_cache=False)

View File

@@ -1,54 +0,0 @@
import pytest
from sqlalchemy.orm import Session
from dibbler.models import Product, TransactionType
from dibbler.queries import product_stock
from tests.benchmark.benchmark_settings import BENCHMARK_ITERATIONS, BENCHMARK_ROUNDS
from tests.benchmark.helpers import generate_random_transactions, insert_users_and_products
@pytest.mark.benchmark(group="product_stock")
@pytest.mark.parametrize(
"transaction_count",
[
100,
500,
1000,
2000,
5000,
10000,
],
)
def test_benchmark_product_stock(
benchmark,
sql_session: Session,
transaction_count: int,
):
_users, products = insert_users_and_products(sql_session)
transactions = generate_random_transactions(
sql_session,
transaction_count,
transaction_type_filter=[
TransactionType.ADD_PRODUCT,
TransactionType.ADJUST_STOCK,
TransactionType.BUY_PRODUCT,
TransactionType.JOINT,
TransactionType.THROW_PRODUCT,
],
)
sql_session.add_all(transactions)
sql_session.commit()
benchmark.pedantic(
query_all_products_stock,
args=(sql_session, products),
iterations=BENCHMARK_ITERATIONS,
rounds=BENCHMARK_ROUNDS,
)
def query_all_products_stock(sql_session: Session, products: list[Product]) -> None:
for product in products:
product_stock(sql_session, product, use_cache=False)

View File

@@ -1,54 +0,0 @@
import pytest
from sqlalchemy.orm import Session
from dibbler.models import Product, User
from dibbler.queries import transaction_log
from tests.benchmark.benchmark_settings import BENCHMARK_ITERATIONS, BENCHMARK_ROUNDS
from tests.benchmark.helpers import generate_random_transactions, insert_users_and_products
@pytest.mark.benchmark(group="transaction_log")
@pytest.mark.parametrize(
"transaction_count",
[
100,
500,
1000,
2000,
5000,
10000,
],
)
def test_benchmark_transaction_log(
benchmark,
sql_session: Session,
transaction_count: int,
):
users, products = insert_users_and_products(sql_session)
transactions = generate_random_transactions(
sql_session,
transaction_count,
)
sql_session.add_all(transactions)
sql_session.commit()
benchmark.pedantic(
query_transaction_log,
args=(
sql_session,
products,
users,
),
iterations=BENCHMARK_ITERATIONS,
rounds=BENCHMARK_ROUNDS,
)
def query_transaction_log(sql_session: Session, products: list[Product], users: list[User]) -> None:
for user in users:
transaction_log(sql_session, user=user)
for product in products:
transaction_log(sql_session, product=product)

View File

@@ -1,81 +0,0 @@
import pytest
from sqlalchemy.orm import Session
from dibbler.models import User
from dibbler.queries import update_cache, user_balance
from tests.benchmark.benchmark_settings import BENCHMARK_ITERATIONS, BENCHMARK_ROUNDS
from tests.benchmark.helpers import generate_random_transactions, insert_users_and_products
@pytest.mark.benchmark(group="user_balance")
@pytest.mark.parametrize(
"transaction_count",
[
100,
500,
1000,
1500,
2000,
],
)
def test_benchmark_user_balance(
benchmark,
sql_session: Session,
transaction_count: int,
):
users, _products = insert_users_and_products(sql_session)
transactions = generate_random_transactions(
sql_session,
transaction_count,
)
sql_session.add_all(transactions)
sql_session.commit()
benchmark.pedantic(
query_all_users_balance,
args=(sql_session, users),
iterations=BENCHMARK_ITERATIONS,
rounds=BENCHMARK_ROUNDS,
)
@pytest.mark.benchmark(group="user_balance")
@pytest.mark.parametrize(
"transaction_count",
[
1000,
1500,
2000,
],
)
def test_benchmark_user_balance_cache_every_500(
benchmark,
sql_session: Session,
transaction_count: int,
):
users, _products = insert_users_and_products(sql_session)
transactions = generate_random_transactions(
sql_session,
transaction_count,
)
for i in range(0, len(transactions), 500):
update_cache(sql_session)
sql_session.add_all(transactions[i : i + 500])
sql_session.commit()
benchmark.pedantic(
query_all_users_balance,
args=(sql_session, users),
iterations=BENCHMARK_ITERATIONS,
rounds=BENCHMARK_ROUNDS,
)
def query_all_users_balance(sql_session: Session, users: list[User]) -> None:
for user in users:
user_balance(sql_session, user, use_cache=False)

View File

@@ -3,7 +3,7 @@ import logging
import pytest
import sqlparse
from sqlalchemy import create_engine, event
# from sqlalchemy.exc import OperationalError
from sqlalchemy.exc import OperationalError
from sqlalchemy.orm import Session
from dibbler.models import Base
@@ -66,7 +66,6 @@ def sql_session(request):
Base.metadata.create_all(engine)
with Session(engine) as sql_session:
yield sql_session
sql_session.close()
# FIXME: Declaring this hook seems to have a side effect where the database does not

View File

@@ -1,28 +0,0 @@
from datetime import datetime, timedelta
from dibbler.models import Transaction
def assign_times(
transactions: list[Transaction],
start_time: datetime = datetime(2024, 1, 1, 0, 0, 0),
delta: timedelta = timedelta(minutes=1),
) -> None:
"""Assigns datetimes to a list of transactions starting from start_time and incrementing by delta."""
current_time = start_time
for transaction in transactions:
transaction.time = current_time
current_time += delta
def assert_id_order_similar_to_time_order(transactions: list[Transaction]) -> None:
"""Asserts that the order of transaction IDs is similar to the order of their timestamps."""
sorted_by_time = sorted(transactions, key=lambda t: t.time)
sorted_by_id = sorted(transactions, key=lambda t: t.id)
for t1, t2 in zip(sorted_by_time, sorted_by_id):
assert t1.id == t2.id or t1.time == t2.time, (
f"Transaction ID order does not match time order:\n"
f"ID {t1.id} at time {t1.time}\n"
f"ID {t2.id} at time {t2.time}"
)

View File

@@ -123,6 +123,24 @@ def test_transaction_buy_product_more_than_stock(sql_session: Session) -> None:
assert product_stock(sql_session, product) == 1 - 10
def test_transaction_buy_product_dont_allow_no_add_product_transactions(
sql_session: Session,
) -> None:
user, product = insert_test_data(sql_session)
transaction = Transaction.buy_product(
time=datetime(2023, 10, 1, 12, 0, 0),
product_count=1,
user_id=user.id,
product_id=product.id,
)
sql_session.add(transaction)
with pytest.raises(ValueError):
sql_session.commit()
def test_transaction_add_product_deny_amount_over_per_product_times_product_count(
sql_session: Session,
) -> None:

View File

@@ -1,8 +1,10 @@
from datetime import datetime
import pytest
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from dibbler.models import User
from dibbler.models import Product, Transaction, User
def insert_test_data(sql_session: Session) -> User:

View File

View File

@@ -1,4 +1,4 @@
from datetime import datetime, timedelta
from datetime import datetime
import pytest
from sqlalchemy.orm import Session
@@ -15,18 +15,6 @@ def insert_test_data(sql_session: Session) -> User:
return user
def test_adjust_interest_uninitialized_user(sql_session: Session) -> None:
user = User("Uninitialized User")
with pytest.raises(ValueError, match="User must be persisted in the database."):
adjust_interest(
sql_session,
user=user,
new_interest=4,
message="Attempting to adjust interest for uninitialized user",
)
def test_adjust_interest_no_history(sql_session: Session) -> None:
user = insert_test_data(sql_session)
@@ -65,7 +53,6 @@ def test_adjust_interest_existing_history(sql_session: Session) -> None:
user=user,
new_interest=2,
message="Adjusting interest rate",
time=transactions[-1].time + timedelta(days=1),
)
sql_session.commit()

View File

@@ -1,11 +1,11 @@
from datetime import datetime, timedelta
from datetime import datetime
import pytest
from sqlalchemy.orm import Session
from dibbler.models import Transaction, User
from dibbler.models.Transaction import (
DEFAULT_PENALTY_MULTIPLIER_PERCENT,
DEFAULT_PENALTY_MULTIPLIER_PERCENTAGE,
DEFAULT_PENALTY_THRESHOLD,
)
from dibbler.queries import adjust_penalty, current_penalty
@@ -19,30 +19,6 @@ def insert_test_data(sql_session: Session) -> User:
return user
def test_adjust_penalty_empty_not_allowed(sql_session: Session) -> None:
user = insert_test_data(sql_session)
with pytest.raises(ValueError):
adjust_penalty(
sql_session,
user=user,
message="No penalty or multiplier provided",
)
def test_adjust_penalty_uninitialized_user(sql_session: Session) -> None:
user = User("Uninitialized User")
with pytest.raises(ValueError):
adjust_penalty(
sql_session,
user=user,
new_penalty=-100,
new_penalty_multiplier=110,
message="Attempting to adjust penalty for uninitialized user",
)
def test_adjust_penalty_no_history(sql_session: Session) -> None:
user = insert_test_data(sql_session)
@@ -57,7 +33,7 @@ def test_adjust_penalty_no_history(sql_session: Session) -> None:
(penalty, multiplier) = current_penalty(sql_session)
assert penalty == -200
assert multiplier == DEFAULT_PENALTY_MULTIPLIER_PERCENT
assert multiplier == DEFAULT_PENALTY_MULTIPLIER_PERCENTAGE
def test_adjust_penalty_multiplier_no_history(sql_session: Session) -> None:
@@ -124,7 +100,6 @@ def test_adjust_penalty_existing_history(sql_session: Session) -> None:
user=user,
new_penalty=-250,
message="Adjusting penalty threshold",
time=transactions[-1].time + timedelta(days=1),
)
sql_session.commit()
@@ -155,7 +130,6 @@ def test_adjust_penalty_multiplier_existing_history(sql_session: Session) -> Non
user=user,
new_penalty_multiplier=130,
message="Adjusting penalty multiplier",
time=transactions[-1].time + timedelta(days=1),
)
sql_session.commit()
(_, multiplier) = current_penalty(sql_session)

View File

@@ -1,74 +0,0 @@
from datetime import datetime, timedelta
import pytest
from sqlalchemy.orm import Session
from dibbler.models import Product, Transaction, User
from dibbler.queries import affected_products
from tests.helpers import assert_id_order_similar_to_time_order, assign_times
def insert_test_data(sql_session: Session) -> tuple[User, list[Product]]:
user = User("Test User")
products = []
for i in range(10):
product = Product(f"12345678901{i:02d}", f"Test Product {i}")
products.append(product)
sql_session.add(user)
sql_session.add_all(products)
sql_session.commit()
return user, products
def test_affected_products_no_history(sql_session: Session) -> None:
insert_test_data(sql_session)
result = affected_products(sql_session)
assert result == set()
def test_affected_products_basic_history(sql_session: Session) -> None:
user, products = insert_test_data(sql_session)
transactions = [
Transaction.add_product(
amount=10,
per_product=10,
user_id=user.id,
product_id=products[i].id,
product_count=1,
)
for i in range(5)
] + [
Transaction.buy_product(
user_id=user.id,
product_id=products[i].id,
product_count=1,
)
for i in range(3)
]
assign_times(transactions)
sql_session.add_all(transactions)
sql_session.commit()
assert_id_order_similar_to_time_order(transactions)
result = affected_products(sql_session)
expected_products = {products[i] for i in range(5)}
assert result == expected_products
# def test_affected_products_after(sql_session: Session) -> None:
# def test_affected_products_until(sql_session: Session) -> None:
# def test_affected_products_after_until(sql_session: Session) -> None:
# def test_affected_products_after_inclusive(sql_session: Session) -> None:
# def test_affected_products_until_inclusive(sql_session: Session) -> None:
# def test_affected_products_after_until_inclusive(sql_session: Session) -> None:

View File

@@ -1,74 +0,0 @@
from datetime import datetime, timedelta
import pytest
from sqlalchemy.orm import Session
from dibbler.models import Product, Transaction, User
from dibbler.queries import affected_users
from tests.helpers import assert_id_order_similar_to_time_order, assign_times
def insert_test_data(sql_session: Session) -> tuple[list[User], Product]:
users = []
for i in range(10):
user = User(f"Test User {i + 1}")
users.append(user)
product = Product("1234567890123", "Test Product")
sql_session.add_all(users)
sql_session.add(product)
sql_session.commit()
return users, product
def test_affected_users_no_history(sql_session: Session) -> None:
insert_test_data(sql_session)
result = affected_users(sql_session)
assert result == set()
def test_affected_users_basic_history(sql_session: Session) -> None:
users, product = insert_test_data(sql_session)
transactions = [
Transaction.add_product(
amount=10,
per_product=10,
user_id=users[i].id,
product_id=product.id,
product_count=1,
)
for i in range(5)
] + [
Transaction.buy_product(
user_id=users[i].id,
product_id=product.id,
product_count=1,
)
for i in range(3)
]
assign_times(transactions)
sql_session.add_all(transactions)
sql_session.commit()
assert_id_order_similar_to_time_order(transactions)
result = affected_users(sql_session)
expected_users = {users[i] for i in range(5)}
assert result == expected_users
# def test_affected_users_after(sql_session: Session) -> None:
# def test_affected_users_until(sql_session: Session) -> None:
# def test_affected_users_after_until(sql_session: Session) -> None:
# def test_affected_users_after_inclusive(sql_session: Session) -> None:
# def test_affected_users_until_inclusive(sql_session: Session) -> None:
# def test_affected_users_after_until_inclusive(sql_session: Session) -> None:

View File

@@ -1,13 +1,14 @@
from datetime import datetime
from sqlalchemy.orm import Session
from dibbler.models.Transaction import DEFAULT_INTEREST_RATE_PERCENT
from dibbler.models.Transaction import DEFAULT_INTEREST_RATE_PERCENTAGE
from dibbler.models import Transaction, User
from dibbler.queries import current_interest
from tests.helpers import assert_id_order_similar_to_time_order, assign_times
def test_current_interest_no_history(sql_session: Session) -> None:
assert current_interest(sql_session) == DEFAULT_INTEREST_RATE_PERCENT
assert current_interest(sql_session) == DEFAULT_INTEREST_RATE_PERCENTAGE
def test_current_interest_with_history(sql_session: Session) -> None:
@@ -17,20 +18,18 @@ def test_current_interest_with_history(sql_session: Session) -> None:
transactions = [
Transaction.adjust_interest(
time=datetime(2023, 10, 1, 10, 0, 0),
interest_rate_percent=5,
user_id=user.id,
),
Transaction.adjust_interest(
time=datetime(2023, 11, 1, 10, 0, 0),
interest_rate_percent=7,
user_id=user.id,
),
]
assign_times(transactions)
sql_session.add_all(transactions)
sql_session.commit()
assert_id_order_similar_to_time_order(transactions)
assert current_interest(sql_session) == 7

View File

@@ -1,18 +1,19 @@
from datetime import datetime
from sqlalchemy.orm import Session
from dibbler.models import Transaction, User
from dibbler.models.Transaction import (
DEFAULT_PENALTY_MULTIPLIER_PERCENT,
DEFAULT_PENALTY_MULTIPLIER_PERCENTAGE,
DEFAULT_PENALTY_THRESHOLD,
)
from dibbler.queries import current_penalty
from tests.helpers import assign_times, assert_id_order_similar_to_time_order
def test_current_penalty_no_history(sql_session: Session) -> None:
assert current_penalty(sql_session) == (
DEFAULT_PENALTY_THRESHOLD,
DEFAULT_PENALTY_MULTIPLIER_PERCENT,
DEFAULT_PENALTY_MULTIPLIER_PERCENTAGE,
)
@@ -23,22 +24,19 @@ def test_current_penalty_with_history(sql_session: Session) -> None:
transactions = [
Transaction.adjust_penalty(
time=datetime(2023, 10, 1, 10, 0, 0),
penalty_threshold=-200,
penalty_multiplier_percent=150,
user_id=user.id,
),
Transaction.adjust_penalty(
time=datetime(2023, 10, 2, 10, 0, 0),
penalty_threshold=-300,
penalty_multiplier_percent=200,
user_id=user.id,
),
]
assign_times(transactions)
sql_session.add_all(transactions)
sql_session.commit()
assert_id_order_similar_to_time_order(transactions)
assert current_penalty(sql_session) == (-300, 200)

View File

@@ -1,4 +1,4 @@
from datetime import datetime, timedelta
from datetime import datetime
import pytest
from sqlalchemy.orm import Session
@@ -33,12 +33,12 @@ def insert_test_data(sql_session: Session) -> tuple[User, User, User, Product]:
return user1, user2, user3, product
def test_joint_buy_product_uninitialized_product(sql_session: Session) -> None:
def test_joint_buy_product_missing_product(sql_session: Session) -> None:
user = User("Test User 1")
sql_session.add(user)
sql_session.commit()
product = Product("1234567890123", "Uninitialized Product")
product = Product("1234567890123", "Test Product")
with pytest.raises(ValueError):
joint_buy_product(
@@ -50,42 +50,18 @@ def test_joint_buy_product_uninitialized_product(sql_session: Session) -> None:
)
def test_joint_buy_product_no_users(sql_session: Session) -> None:
user, _, _, product = insert_test_data(sql_session)
def test_joint_buy_product_missing_user(sql_session: Session) -> None:
user = User("Test User 1")
product = Product("1234567890123", "Test Product")
sql_session.add(product)
sql_session.commit()
with pytest.raises(ValueError):
joint_buy_product(
sql_session,
instigator=user,
users=[],
product=product,
product_count=1,
)
def test_joint_buy_product_uninitialized_instigator(sql_session: Session) -> None:
user, user2, _, product = insert_test_data(sql_session)
uninitialized_user = User("Uninitialized User")
with pytest.raises(ValueError):
joint_buy_product(
sql_session,
instigator=uninitialized_user,
users=[user, user2],
product=product,
product_count=1,
)
def test_joint_buy_product_uninitialized_user_in_list(sql_session: Session) -> None:
user, _, _, product = insert_test_data(sql_session)
uninitialized_user = User("Uninitialized User")
with pytest.raises(ValueError):
joint_buy_product(
sql_session,
instigator=user,
users=[user, uninitialized_user],
users=[user],
product=product,
product_count=1,
)
@@ -170,7 +146,6 @@ def test_joint_buy_product_out_of_stock(sql_session: Session) -> None:
users=[user, user2, user3],
product=product,
product_count=10,
time=transactions[-1].time + timedelta(days=1),
)

View File

@@ -1,12 +1,11 @@
from datetime import datetime
from pprint import pprint
import pytest
from sqlalchemy.orm import Session
from dibbler.models import Product, User
from dibbler.models.Transaction import Transaction
from dibbler.queries import product_owners, product_owners_log, product_stock
from tests.helpers import assign_times, assert_id_order_similar_to_time_order
def insert_test_data(sql_session: Session) -> tuple[Product, User]:
@@ -21,17 +20,6 @@ def insert_test_data(sql_session: Session) -> tuple[Product, User]:
return product, user
def test_product_owners_unitilialized_product(sql_session: Session) -> None:
user = User("testuser")
sql_session.add(user)
sql_session.commit()
product = Product("1234567890123", "Uninitialized Product")
with pytest.raises(ValueError):
product_owners(sql_session, product)
def test_product_owners_no_transactions(sql_session: Session) -> None:
product, _ = insert_test_data(sql_session)
@@ -51,16 +39,12 @@ def test_product_owners_add_products(sql_session: Session) -> None:
amount=30,
per_product=10,
product_count=3,
time=datetime(2024, 1, 1, 10, 0, 0),
)
]
assign_times(transactions)
sql_session.add_all(transactions)
sql_session.commit()
assert_id_order_similar_to_time_order(transactions)
pprint(product_owners_log(sql_session, product))
owners = product_owners(sql_session, product)
@@ -76,21 +60,19 @@ def test_product_owners_add_and_buy_products(sql_session: Session) -> None:
amount=30,
per_product=10,
product_count=3,
time=datetime(2024, 1, 1, 10, 0, 0),
),
Transaction.buy_product(
user_id=user.id,
product_id=product.id,
product_count=1,
time=datetime(2024, 1, 2, 10, 0, 0),
),
]
assign_times(transactions)
sql_session.add_all(transactions)
sql_session.commit()
assert_id_order_similar_to_time_order(transactions)
pprint(product_owners_log(sql_session, product))
owners = product_owners(sql_session, product)
@@ -106,21 +88,19 @@ def test_product_owners_add_and_throw_products(sql_session: Session) -> None:
amount=40,
per_product=10,
product_count=4,
time=datetime(2024, 1, 1, 10, 0, 0),
),
Transaction.throw_product(
user_id=user.id,
product_id=product.id,
product_count=2,
time=datetime(2024, 1, 2, 10, 0, 0),
),
]
assign_times(transactions)
sql_session.add_all(transactions)
sql_session.commit()
assert_id_order_similar_to_time_order(transactions)
pprint(product_owners_log(sql_session, product))
owners = product_owners(sql_session, product)
@@ -139,6 +119,7 @@ def test_product_owners_multiple_users(sql_session: Session) -> None:
amount=20,
per_product=10,
product_count=2,
time=datetime(2024, 1, 1, 10, 0, 0),
),
Transaction.add_product(
user_id=user2.id,
@@ -146,16 +127,13 @@ def test_product_owners_multiple_users(sql_session: Session) -> None:
amount=30,
per_product=10,
product_count=3,
time=datetime(2024, 1, 2, 10, 0, 0),
),
]
assign_times(transactions)
sql_session.add_all(transactions)
sql_session.commit()
assert_id_order_similar_to_time_order(transactions)
pprint(product_owners_log(sql_session, product))
owners = product_owners(sql_session, product)
@@ -172,21 +150,18 @@ def test_product_owners_adjust_stock_down(sql_session: Session) -> None:
amount=50,
per_product=10,
product_count=5,
time=datetime(2024, 1, 1, 10, 0, 0),
),
Transaction.adjust_stock(
user_id=user.id,
product_id=product.id,
product_count=-2,
time=datetime(2024, 1, 2, 10, 0, 0),
),
]
assign_times(transactions)
sql_session.add_all(transactions)
sql_session.commit()
assert_id_order_similar_to_time_order(transactions)
pprint(product_owners_log(sql_session, product))
assert product_stock(sql_session, product) == 3
@@ -205,21 +180,18 @@ def test_product_owners_adjust_stock_up(sql_session: Session) -> None:
amount=20,
per_product=10,
product_count=2,
time=datetime(2024, 1, 1, 10, 0, 0),
),
Transaction.adjust_stock(
user_id=user.id,
product_id=product.id,
product_count=3,
time=datetime(2024, 1, 2, 10, 0, 0),
),
]
assign_times(transactions)
sql_session.add_all(transactions)
sql_session.commit()
assert_id_order_similar_to_time_order(transactions)
pprint(product_owners_log(sql_session, product))
owners = product_owners(sql_session, product)
@@ -236,21 +208,18 @@ def test_product_owners_negative_stock(sql_session: Session) -> None:
amount=10,
per_product=10,
product_count=1,
time=datetime(2024, 1, 1, 10, 0, 0),
),
Transaction.buy_product(
user_id=user.id,
product_id=product.id,
product_count=2,
time=datetime(2024, 1, 2, 10, 0, 0),
),
]
assign_times(transactions)
sql_session.add_all(transactions)
sql_session.commit()
assert_id_order_similar_to_time_order(transactions)
owners = product_owners(sql_session, product)
assert owners == []
@@ -263,6 +232,7 @@ def test_product_owners_add_products_from_negative_stock(sql_session: Session) -
user_id=user.id,
product_id=product.id,
product_count=2,
time=datetime(2024, 1, 1, 10, 0, 0),
),
Transaction.add_product(
user_id=user.id,
@@ -270,16 +240,12 @@ def test_product_owners_add_products_from_negative_stock(sql_session: Session) -
amount=30,
per_product=10,
product_count=3,
time=datetime(2024, 1, 2, 10, 0, 0),
),
]
assign_times(transactions)
sql_session.add_all(transactions)
sql_session.commit()
assert_id_order_similar_to_time_order(transactions)
pprint(product_owners_log(sql_session, product))
owners = product_owners(sql_session, product)
@@ -299,6 +265,7 @@ def test_product_owners_interleaved_users(sql_session: Session) -> None:
amount=20,
per_product=10,
product_count=2,
time=datetime(2024, 1, 1, 10, 0, 0),
),
Transaction.add_product(
user_id=user2.id,
@@ -306,11 +273,13 @@ def test_product_owners_interleaved_users(sql_session: Session) -> None:
amount=30,
per_product=10,
product_count=3,
time=datetime(2024, 1, 2, 10, 0, 0),
),
Transaction.buy_product(
user_id=user1.id,
product_id=product.id,
product_count=1,
time=datetime(2024, 1, 3, 10, 0, 0),
),
Transaction.add_product(
user_id=user1.id,
@@ -318,16 +287,12 @@ def test_product_owners_interleaved_users(sql_session: Session) -> None:
amount=10,
per_product=10,
product_count=1,
time=datetime(2024, 1, 4, 10, 0, 0),
),
]
assign_times(transactions)
sql_session.add_all(transactions)
sql_session.commit()
assert_id_order_similar_to_time_order(transactions)
pprint(product_owners_log(sql_session, product))
owners = product_owners(sql_session, product)

View File

@@ -1,13 +1,11 @@
import math
from datetime import datetime, timedelta
from datetime import datetime
from pprint import pprint
import pytest
from sqlalchemy.orm import Session
from dibbler.models import Product, Transaction, User
from dibbler.queries import product_price, product_price_log, joint_buy_product
from tests.helpers import assert_id_order_similar_to_time_order, assign_times
# TODO: see if we can use pytest_runtest_makereport to print the "product_price_log"s
# only on failures instead of inlining it in every test function
@@ -24,17 +22,6 @@ def insert_test_data(sql_session: Session) -> tuple[User, Product]:
return user, product
def test_product_price_uninitialized_product(sql_session: Session) -> None:
user = User("Test User")
sql_session.add(user)
sql_session.commit()
product = Product("1234567890123", "Uninitialized Product")
with pytest.raises(ValueError, match="Product must be persisted in the database."):
product_price(sql_session, product)
def test_product_price_no_transactions(sql_session: Session) -> None:
_, product = insert_test_data(sql_session)
@@ -70,6 +57,7 @@ def test_product_price_sold_out(sql_session: Session) -> None:
transactions = [
Transaction.add_product(
time=datetime(2023, 10, 1, 12, 0, 0),
amount=27 * 2 - 1,
per_product=27,
product_count=2,
@@ -77,19 +65,16 @@ def test_product_price_sold_out(sql_session: Session) -> None:
product_id=product.id,
),
Transaction.buy_product(
time=datetime(2023, 10, 1, 12, 0, 1),
product_count=2,
user_id=user.id,
product_id=product.id,
),
]
assign_times(transactions)
sql_session.add_all(transactions)
sql_session.commit()
assert_id_order_similar_to_time_order(transactions)
pprint(product_price_log(sql_session, product))
assert product_price(sql_session, product) == 27
@@ -100,10 +85,12 @@ def test_product_price_interest(sql_session: Session) -> None:
transactions = [
Transaction.adjust_interest(
time=datetime(2023, 10, 1, 12, 0, 0),
interest_rate_percent=110,
user_id=user.id,
),
Transaction.add_product(
time=datetime(2023, 10, 1, 12, 0, 1),
amount=27 * 2 - 1,
per_product=27,
product_count=2,
@@ -112,13 +99,9 @@ def test_product_price_interest(sql_session: Session) -> None:
),
]
assign_times(transactions)
sql_session.add_all(transactions)
sql_session.commit()
assert_id_order_similar_to_time_order(transactions)
pprint(product_price_log(sql_session, product))
product_price_ = product_price(sql_session, product)
@@ -133,10 +116,12 @@ def test_product_price_changing_interest(sql_session: Session) -> None:
transactions = [
Transaction.adjust_interest(
time=datetime(2023, 10, 1, 12, 0, 0),
interest_rate_percent=110,
user_id=user.id,
),
Transaction.add_product(
time=datetime(2023, 10, 1, 12, 0, 1),
amount=27 * 2 - 1,
per_product=27,
product_count=2,
@@ -144,18 +129,15 @@ def test_product_price_changing_interest(sql_session: Session) -> None:
product_id=product.id,
),
Transaction.adjust_interest(
time=datetime(2023, 10, 1, 12, 0, 2),
interest_rate_percent=120,
user_id=user.id,
),
]
assign_times(transactions)
sql_session.add_all(transactions)
sql_session.commit()
assert_id_order_similar_to_time_order(transactions)
pprint(product_price_log(sql_session, product))
product_price_interest = product_price(sql_session, product, include_interest=True)
@@ -167,6 +149,7 @@ def test_product_price_old_transaction(sql_session: Session) -> None:
transactions = [
Transaction.add_product(
time=datetime(2023, 10, 1, 12, 0, 1),
amount=27 * 2,
per_product=27,
product_count=2,
@@ -175,6 +158,7 @@ def test_product_price_old_transaction(sql_session: Session) -> None:
),
# Price should be 27
Transaction.add_product(
time=datetime(2023, 10, 1, 12, 0, 2),
amount=38 * 3,
per_product=38,
product_count=3,
@@ -184,27 +168,23 @@ def test_product_price_old_transaction(sql_session: Session) -> None:
# price should be averaged upwards
]
assign_times(transactions)
sql_session.add_all(transactions)
sql_session.commit()
assert_id_order_similar_to_time_order(transactions)
until_transaction = transactions[0]
pprint(
product_price_log(
sql_session,
product,
until_transaction=until_transaction,
until=until_transaction,
)
)
product_price_ = product_price(
sql_session,
product,
until_transaction=until_transaction,
until=until_transaction,
)
assert product_price_ == 27
@@ -215,6 +195,7 @@ def test_product_price_round_up_from_below(sql_session: Session) -> None:
transactions = [
Transaction.add_product(
time=datetime(2023, 10, 1, 12, 0, 1),
amount=27 * 2,
per_product=27,
product_count=2,
@@ -223,6 +204,7 @@ def test_product_price_round_up_from_below(sql_session: Session) -> None:
),
# Price should be 27
Transaction.add_product(
time=datetime(2023, 10, 1, 12, 0, 2),
amount=38 * 3,
per_product=38,
product_count=3,
@@ -232,13 +214,9 @@ def test_product_price_round_up_from_below(sql_session: Session) -> None:
# price should be averaged upwards
]
assign_times(transactions)
sql_session.add_all(transactions)
sql_session.commit()
assert_id_order_similar_to_time_order(transactions)
pprint(product_price_log(sql_session, product))
product_price_ = product_price(sql_session, product)
@@ -251,6 +229,7 @@ def test_product_price_round_up_from_above(sql_session: Session) -> None:
transactions = [
Transaction.add_product(
time=datetime(2023, 10, 1, 12, 0, 1),
amount=27 * 2,
per_product=27,
product_count=2,
@@ -259,6 +238,7 @@ def test_product_price_round_up_from_above(sql_session: Session) -> None:
),
# Price should be 27
Transaction.add_product(
time=datetime(2023, 10, 1, 12, 0, 2),
amount=20 * 3,
per_product=20,
product_count=3,
@@ -268,13 +248,9 @@ def test_product_price_round_up_from_above(sql_session: Session) -> None:
# price should be averaged downwards
]
assign_times(transactions)
sql_session.add_all(transactions)
sql_session.commit()
assert_id_order_similar_to_time_order(transactions)
pprint(product_price_log(sql_session, product))
product_price_ = product_price(sql_session, product)
@@ -286,6 +262,7 @@ def test_product_price_with_negative_stock_single_addition(sql_session: Session)
transactions = [
Transaction.add_product(
time=datetime(2023, 10, 1, 13, 0, 0),
amount=1,
per_product=10,
product_count=1,
@@ -293,11 +270,13 @@ def test_product_price_with_negative_stock_single_addition(sql_session: Session)
product_id=product.id,
),
Transaction.buy_product(
time=datetime(2023, 10, 1, 13, 0, 1),
product_count=10,
user_id=user.id,
product_id=product.id,
),
Transaction.add_product(
time=datetime(2023, 10, 1, 13, 0, 2),
amount=22,
per_product=22,
product_count=1,
@@ -306,13 +285,9 @@ def test_product_price_with_negative_stock_single_addition(sql_session: Session)
),
]
assign_times(transactions)
sql_session.add_all(transactions)
sql_session.commit()
assert_id_order_similar_to_time_order(transactions)
pprint(product_price_log(sql_session, product))
# Stock went subzero, price should be the last added product price
@@ -320,11 +295,13 @@ def test_product_price_with_negative_stock_single_addition(sql_session: Session)
assert product1_price == 22
# TODO: what happens when stock is still negative and yet new products are added?
def test_product_price_with_negative_stock_multiple_additions(sql_session: Session) -> None:
user, product = insert_test_data(sql_session)
transactions = [
Transaction.add_product(
time=datetime(2023, 10, 1, 13, 0, 0),
amount=1,
per_product=10,
product_count=1,
@@ -332,11 +309,13 @@ def test_product_price_with_negative_stock_multiple_additions(sql_session: Sessi
product_id=product.id,
),
Transaction.buy_product(
time=datetime(2023, 10, 1, 13, 0, 1),
product_count=10,
user_id=user.id,
product_id=product.id,
),
Transaction.add_product(
time=datetime(2023, 10, 1, 13, 0, 2),
amount=22,
per_product=22,
product_count=1,
@@ -344,6 +323,7 @@ def test_product_price_with_negative_stock_multiple_additions(sql_session: Sessi
product_id=product.id,
),
Transaction.add_product(
time=datetime(2023, 10, 1, 13, 0, 3),
amount=29,
per_product=29,
product_count=2,
@@ -352,18 +332,14 @@ def test_product_price_with_negative_stock_multiple_additions(sql_session: Sessi
),
]
assign_times(transactions)
sql_session.add_all(transactions)
sql_session.commit()
assert_id_order_similar_to_time_order(transactions)
pprint(product_price_log(sql_session, product))
# Stock went subzero, price should be the last added product price
# Stock went subzero, price should be the ceiled average of the last added products
product1_price = product_price(sql_session, product)
assert product1_price == math.ceil(29)
assert product1_price == math.ceil((22 + 29 * 2) / (1 + 2))
def test_product_price_joint_transactions(sql_session: Session) -> None:
@@ -374,6 +350,7 @@ def test_product_price_joint_transactions(sql_session: Session) -> None:
transactions = [
Transaction.add_product(
time=datetime(2023, 10, 1, 12, 0, 0),
amount=30 * 3,
per_product=30,
product_count=3,
@@ -381,6 +358,7 @@ def test_product_price_joint_transactions(sql_session: Session) -> None:
product_id=product.id,
),
Transaction.add_product(
time=datetime(2023, 10, 1, 12, 0, 1),
amount=20 * 2,
per_product=20,
product_count=2,
@@ -389,23 +367,19 @@ def test_product_price_joint_transactions(sql_session: Session) -> None:
),
]
assign_times(transactions)
sql_session.add_all(transactions)
sql_session.commit()
assert_id_order_similar_to_time_order(transactions)
product_price_ = product_price(sql_session, product)
assert product_price_ == math.ceil((30 * 3 + 20 * 2) / (3 + 2))
transactions += joint_buy_product(
joint_buy_product(
sql_session,
time=datetime(2023, 10, 1, 12, 0, 2),
instigator=user1,
users=[user1, user2],
product=product,
product_count=2,
time=transactions[-1].time + timedelta(seconds=1),
)
pprint(product_price_log(sql_session, product))
@@ -418,12 +392,12 @@ def test_product_price_joint_transactions(sql_session: Session) -> None:
transactions = [
Transaction.add_product(
time=datetime(2023, 10, 1, 12, 0, 3),
amount=25 * 4,
per_product=25,
product_count=4,
user_id=user1.id,
product_id=product.id,
time=transactions[-1].time + timedelta(seconds=1),
),
]

View File

@@ -1,11 +1,12 @@
from datetime import datetime, timedelta
from datetime import datetime
import pytest
from sqlalchemy import select
from sqlalchemy.orm import Session
from dibbler.models import Product, Transaction, User
from dibbler.models.TransactionType import TransactionTypeSQL
from dibbler.queries import joint_buy_product, product_stock
from tests.helpers import assert_id_order_similar_to_time_order, assign_times
def insert_test_data(sql_session: Session) -> tuple[User, Product]:
@@ -17,37 +18,6 @@ def insert_test_data(sql_session: Session) -> tuple[User, Product]:
return user, product
def test_product_stock_uninitialized_product(sql_session: Session) -> None:
user = User("Test User 1")
sql_session.add(user)
sql_session.commit()
product = Product("1234567890123", "Uninitialized Product")
with pytest.raises(ValueError):
product_stock(sql_session, product)
def test_product_stock_until_datetime_and_transaction_id_not_allowed(sql_session: Session) -> None:
user, product = insert_test_data(sql_session)
transaction = Transaction.add_product(
amount=10,
per_product=10,
user_id=user.id,
product_id=product.id,
product_count=1,
)
with pytest.raises(ValueError):
product_stock(
sql_session,
product,
until_time=datetime.now(),
until_transaction=transaction,
)
def test_product_stock_basic_history(sql_session: Session) -> None:
user, product = insert_test_data(sql_session)
@@ -55,6 +25,7 @@ def test_product_stock_basic_history(sql_session: Session) -> None:
transactions = [
Transaction.add_product(
time=datetime(2023, 10, 1, 12, 0, 0),
amount=10,
per_product=10,
user_id=user.id,
@@ -79,21 +50,18 @@ def test_product_stock_adjust_stock_up(sql_session: Session) -> None:
amount=50,
per_product=10,
product_count=5,
time=datetime(2024, 1, 1, 10, 0, 0),
),
Transaction.adjust_stock(
user_id=user.id,
product_id=product.id,
product_count=2,
time=datetime(2024, 1, 2, 10, 0, 0),
),
]
assign_times(transactions)
sql_session.add_all(transactions)
sql_session.commit()
assert_id_order_similar_to_time_order(transactions)
assert product_stock(sql_session, product) == 5 + 2
@@ -107,21 +75,18 @@ def test_product_stock_adjust_stock_down(sql_session: Session) -> None:
amount=50,
per_product=10,
product_count=5,
time=datetime(2024, 1, 1, 10, 0, 0),
),
Transaction.adjust_stock(
user_id=user.id,
product_id=product.id,
product_count=-2,
time=datetime(2024, 1, 2, 10, 0, 0),
),
]
assign_times(transactions)
sql_session.add_all(transactions)
sql_session.commit()
assert_id_order_similar_to_time_order(transactions)
assert product_stock(sql_session, product) == 5 - 2
@@ -130,6 +95,7 @@ def test_product_stock_complex_history(sql_session: Session) -> None:
transactions = [
Transaction.add_product(
time=datetime(2023, 10, 1, 13, 0, 0),
amount=27 * 2,
per_product=27,
user_id=user.id,
@@ -137,11 +103,13 @@ def test_product_stock_complex_history(sql_session: Session) -> None:
product_count=2,
),
Transaction.buy_product(
time=datetime(2023, 10, 1, 13, 0, 1),
user_id=user.id,
product_id=product.id,
product_count=3,
),
Transaction.add_product(
time=datetime(2023, 10, 1, 13, 0, 2),
amount=50 * 4,
per_product=50,
user_id=user.id,
@@ -149,29 +117,28 @@ def test_product_stock_complex_history(sql_session: Session) -> None:
product_count=4,
),
Transaction.adjust_stock(
time=datetime(2023, 10, 1, 15, 0, 0),
user_id=user.id,
product_id=product.id,
product_count=3,
),
Transaction.adjust_stock(
time=datetime(2023, 10, 1, 15, 0, 1),
user_id=user.id,
product_id=product.id,
product_count=-2,
),
Transaction.throw_product(
time=datetime(2023, 10, 1, 15, 0, 2),
user_id=user.id,
product_id=product.id,
product_count=1,
),
]
assign_times(transactions)
sql_session.add_all(transactions)
sql_session.commit()
assert_id_order_similar_to_time_order(transactions)
assert product_stock(sql_session, product) == 2 - 3 + 4 + 3 - 2 - 1
@@ -186,6 +153,7 @@ def test_negative_product_stock(sql_session: Session) -> None:
transactions = [
Transaction.add_product(
time=datetime(2023, 10, 1, 14, 0, 0),
amount=50,
per_product=50,
user_id=user.id,
@@ -193,24 +161,22 @@ def test_negative_product_stock(sql_session: Session) -> None:
product_count=1,
),
Transaction.buy_product(
time=datetime(2023, 10, 1, 14, 0, 1),
user_id=user.id,
product_id=product.id,
product_count=2,
),
Transaction.adjust_stock(
time=datetime(2023, 10, 1, 16, 0, 0),
user_id=user.id,
product_id=product.id,
product_count=-1,
),
]
assign_times(transactions)
sql_session.add_all(transactions)
sql_session.commit()
assert_id_order_similar_to_time_order(transactions)
# The stock should be negative because we added and bought the product
assert product_stock(sql_session, product) == 1 - 2 - 1
@@ -238,7 +204,7 @@ def test_product_stock_joint_transaction(sql_session: Session) -> None:
joint_buy_product(
sql_session,
time=transactions[0].time + timedelta(seconds=1),
time=datetime(2023, 10, 1, 17, 0, 1),
instigator=user1,
users=[user1, user2],
product=product,
@@ -248,11 +214,12 @@ def test_product_stock_joint_transaction(sql_session: Session) -> None:
assert product_stock(sql_session, product) == 5 - 3
def test_product_stock_until_time(sql_session: Session) -> None:
def test_product_stock_until(sql_session: Session) -> None:
user, product = insert_test_data(sql_session)
transactions = [
Transaction.add_product(
time=datetime(2023, 10, 1, 12, 0, 0),
amount=10,
per_product=10,
user_id=user.id,
@@ -260,6 +227,7 @@ def test_product_stock_until_time(sql_session: Session) -> None:
product_count=1,
),
Transaction.add_product(
time=datetime(2023, 10, 2, 12, 0, 0),
amount=20,
per_product=10,
user_id=user.id,
@@ -267,19 +235,7 @@ def test_product_stock_until_time(sql_session: Session) -> None:
product_count=2,
),
]
assign_times(transactions)
sql_session.add_all(transactions)
sql_session.commit()
assert_id_order_similar_to_time_order(transactions)
assert (
product_stock(
sql_session,
product,
until_time=transactions[-1].time - timedelta(seconds=1),
)
== 1
)
assert product_stock(sql_session, product, until=datetime(2023, 10, 1, 23, 59, 59)) == 1

View File

@@ -1,4 +1,3 @@
import pytest
from sqlalchemy.orm import Session
from dibbler.models import Product
@@ -19,13 +18,6 @@ def insert_test_data(sql_session: Session) -> list[Product]:
return products
def test_search_product_empty_not_allowed(sql_session: Session) -> None:
insert_test_data(sql_session)
with pytest.raises(ValueError):
search_product("", sql_session)
def test_search_product_no_products(sql_session: Session) -> None:
result = search_product("Nonexistent Product", sql_session)

View File

@@ -1,5 +1,4 @@
from sqlalchemy.orm import Session
import pytest
from dibbler.models import User
from dibbler.queries import search_user
@@ -24,13 +23,6 @@ def setup_users(sql_session: Session) -> None:
sql_session.commit()
def test_search_user_empty_not_allowed(sql_session: Session) -> None:
setup_users(sql_session)
with pytest.raises(ValueError):
search_user("", sql_session)
def test_search_user_exact_match(sql_session: Session) -> None:
setup_users(sql_session)

View File

@@ -10,7 +10,6 @@ from dibbler.models import (
User,
)
from dibbler.queries import transaction_log
from tests.helpers import assert_id_order_similar_to_time_order, assign_times
def insert_test_data(sql_session: Session) -> tuple[User, User, Product, Product]:
@@ -34,18 +33,22 @@ def insert_default_test_transactions(
) -> list[Transaction]:
transactions = [
Transaction.adjust_balance(
time=datetime(2023, 10, 1, 10, 0, 0),
amount=100,
user_id=user1.id,
),
Transaction.adjust_balance(
time=datetime(2023, 10, 1, 10, 0, 1),
amount=50,
user_id=user2.id,
),
Transaction.adjust_balance(
time=datetime(2023, 10, 1, 10, 0, 2),
amount=-50,
user_id=user1.id,
),
Transaction.add_product(
time=datetime(2023, 10, 1, 12, 0, 0),
amount=27 * 2,
per_product=27,
product_count=2,
@@ -53,11 +56,13 @@ def insert_default_test_transactions(
product_id=product1.id,
),
Transaction.buy_product(
time=datetime(2023, 10, 1, 12, 0, 1),
product_count=1,
user_id=user2.id,
product_id=product2.id,
),
Transaction.add_product(
time=datetime(2023, 10, 1, 12, 0, 2),
amount=15 * 1,
per_product=15,
product_count=1,
@@ -65,127 +70,19 @@ def insert_default_test_transactions(
product_id=product2.id,
),
Transaction.transfer(
time=datetime(2023, 10, 1, 14, 0, 0),
amount=30,
user_id=user1.id,
transfer_user_id=user2.id,
),
]
assign_times(transactions)
sql_session.add_all(transactions)
sql_session.commit()
assert_id_order_similar_to_time_order(transactions)
return transactions
def test_transaction_log_invalid_limit(sql_session: Session) -> None:
with pytest.raises(ValueError):
transaction_log(sql_session, limit=0)
with pytest.raises(ValueError):
transaction_log(sql_session, limit=-1)
def test_transaction_log_uninitialized_user(sql_session: Session) -> None:
user = User("Uninitialized User")
with pytest.raises(ValueError):
transaction_log(sql_session, user=user)
def test_transaction_log_uninitialized_product(sql_session: Session) -> None:
product = Product("1234567890123", "Uninitialized Product")
with pytest.raises(ValueError):
transaction_log(sql_session, product=product)
def test_transaction_log_uninitialized_after_until_transaction(sql_session: Session) -> None:
user, user2, product, product2 = insert_test_data(sql_session)
insert_default_test_transactions(sql_session, user, user2, product, product2)
uninitialized_transaction = Transaction.adjust_balance(
time=datetime(2023, 10, 1, 10, 0, 0),
amount=100,
user_id=user.id,
)
with pytest.raises(ValueError):
transaction_log(
sql_session,
user=user,
after_transaction=uninitialized_transaction,
)
with pytest.raises(ValueError):
transaction_log(
sql_session,
user=user,
until_transaction=uninitialized_transaction,
)
def test_transaction_log_product_and_user_not_allowed(sql_session: Session) -> None:
user, user2, product, product2 = insert_test_data(sql_session)
insert_default_test_transactions(sql_session, user, user2, product, product2)
with pytest.raises(ValueError):
transaction_log(
sql_session,
user=user,
product=product,
)
def test_transaction_log_until_datetime_and_transaction_id_not_allowed(
sql_session: Session,
) -> None:
user, user2, product, product2 = insert_test_data(sql_session)
insert_default_test_transactions(sql_session, user, user2, product, product2)
trx = Transaction.adjust_balance(
time=datetime(2023, 10, 1, 10, 0, 0),
amount=100,
user_id=user.id,
)
sql_session.add(trx)
sql_session.commit()
with pytest.raises(ValueError):
transaction_log(
sql_session,
user=user,
until_time=datetime(2023, 10, 1, 11, 0, 0),
until_transaction=trx,
)
def test_transaction_log_after_datetime_and_transaction_id_not_allowed(
sql_session: Session,
) -> None:
user, user2, product, product2 = insert_test_data(sql_session)
insert_default_test_transactions(sql_session, user, user2, product, product2)
trx = Transaction.adjust_balance(
time=datetime(2023, 10, 1, 10, 0, 0),
amount=100,
user_id=user.id,
)
sql_session.add(trx)
sql_session.commit()
with pytest.raises(ValueError):
transaction_log(
sql_session,
user=user,
after_time=datetime(2023, 10, 1, 15, 0, 0),
after_transaction=trx,
)
def test_user_transactions_no_transactions(sql_session: Session) -> None:
insert_test_data(sql_session)
@@ -194,13 +91,6 @@ def test_user_transactions_no_transactions(sql_session: Session) -> None:
assert len(transactions) == 0
def test_transaction_log_basic(sql_session: Session) -> None:
user, user2, product, product2 = insert_test_data(sql_session)
insert_default_test_transactions(sql_session, user, user2, product, product2)
assert len(transaction_log(sql_session)) == 7
def test_transaction_log_filtered_by_user(sql_session: Session) -> None:
user, user2, product, product2 = insert_test_data(sql_session)
insert_default_test_transactions(sql_session, user, user2, product, product2)
@@ -255,7 +145,7 @@ def test_transaction_log_after_datetime_exclusive(sql_session: Session) -> None:
transaction_log(
sql_session,
after_time=transactions[2].time,
after_inclusive=False,
exclusive_after=True,
)
)
== len(transactions) - 3
@@ -271,7 +161,7 @@ def test_transaction_log_after_transaction_id(sql_session: Session) -> None:
assert len(
transaction_log(
sql_session,
after_transaction=first_transaction,
after_transaction_id=first_transaction.id,
)
) == len(transactions)
@@ -286,7 +176,7 @@ def test_transaction_log_after_transaction_id_one_transaction(sql_session: Sessi
len(
transaction_log(
sql_session,
after_transaction=last_transaction,
after_transaction_id=last_transaction.id,
)
)
== 1
@@ -303,15 +193,15 @@ def test_transaction_log_after_transaction_id_exclusive(sql_session: Session) ->
len(
transaction_log(
sql_session,
after_transaction=third_transaction,
after_inclusive=False,
after_transaction_id=third_transaction.id,
exclusive_after=True,
)
)
== len(transactions) - 3
)
def test_transaction_log_until_datetime(sql_session: Session) -> None:
def test_transaction_log_before_datetime(sql_session: Session) -> None:
user, user2, product, product2 = insert_test_data(sql_session)
transactions = insert_default_test_transactions(sql_session, user, user2, product, product2)
@@ -319,14 +209,14 @@ def test_transaction_log_until_datetime(sql_session: Session) -> None:
len(
transaction_log(
sql_session,
until_time=transactions[-3].time,
before_time=transactions[-3].time,
)
)
== len(transactions) - 2
)
def test_transaction_log_until_datetime_no_transactions(sql_session: Session) -> None:
def test_transaction_log_before_datetime_no_transactions(sql_session: Session) -> None:
user, user2, product, product2 = insert_test_data(sql_session)
transactions = insert_default_test_transactions(sql_session, user, user2, product, product2)
@@ -334,14 +224,14 @@ def test_transaction_log_until_datetime_no_transactions(sql_session: Session) ->
len(
transaction_log(
sql_session,
until_time=transactions[0].time - timedelta(seconds=1),
before_time=transactions[0].time - timedelta(seconds=1),
)
)
== 0
)
def test_transaction_log_until_datetime_exclusive(sql_session: Session) -> None:
def test_transaction_log_before_datetime_exclusive(sql_session: Session) -> None:
user, user2, product, product2 = insert_test_data(sql_session)
transactions = insert_default_test_transactions(sql_session, user, user2, product, product2)
@@ -349,15 +239,15 @@ def test_transaction_log_until_datetime_exclusive(sql_session: Session) -> None:
len(
transaction_log(
sql_session,
until_time=transactions[-3].time,
until_inclusive=False,
before_time=transactions[-3].time,
exclusive_before=True,
)
)
== len(transactions) - 3
)
def test_transaction_log_until_transaction(sql_session: Session) -> None:
def test_transaction_log_before_transaction_id(sql_session: Session) -> None:
user, user2, product, product2 = insert_test_data(sql_session)
transactions = insert_default_test_transactions(sql_session, user, user2, product, product2)
@@ -367,14 +257,14 @@ def test_transaction_log_until_transaction(sql_session: Session) -> None:
len(
transaction_log(
sql_session,
until_transaction=last_transaction,
before_transaction_id=last_transaction.id,
)
)
== len(transactions) - 2
)
def test_transaction_log_until_transaction_one_transaction(sql_session: Session) -> None:
def test_transaction_log_before_transaction_id_one_transaction(sql_session: Session) -> None:
user, user2, product, product2 = insert_test_data(sql_session)
transactions = insert_default_test_transactions(sql_session, user, user2, product, product2)
@@ -384,14 +274,14 @@ def test_transaction_log_until_transaction_one_transaction(sql_session: Session)
len(
transaction_log(
sql_session,
until_transaction=first_transaction,
before_transaction_id=first_transaction.id,
)
)
== 1
)
def test_transaction_log_until_transaction_exclusive(sql_session: Session) -> None:
def test_transaction_log_before_transaction_id_exclusive(sql_session: Session) -> None:
user, user2, product, product2 = insert_test_data(sql_session)
transactions = insert_default_test_transactions(sql_session, user, user2, product, product2)
@@ -401,30 +291,15 @@ def test_transaction_log_until_transaction_exclusive(sql_session: Session) -> No
len(
transaction_log(
sql_session,
until_transaction=last_transaction,
until_inclusive=False,
before_transaction_id=last_transaction.id,
exclusive_before=True,
)
)
== len(transactions) - 3
)
def test_transaction_log_after_until_datetime_illegal_order(sql_session: Session) -> None:
user, user2, product, product2 = insert_test_data(sql_session)
transactions = insert_default_test_transactions(sql_session, user, user2, product, product2)
second_transaction = transactions[1]
fifth_transaction = transactions[4]
with pytest.raises(ValueError):
transaction_log(
sql_session,
after_time=fifth_transaction.time,
until_time=second_transaction.time,
)
def test_transaction_log_after_until_datetime_combined(sql_session: Session) -> None:
def test_transaction_log_before_after_datetime_combined(sql_session: Session) -> None:
user, user2, product, product2 = insert_test_data(sql_session)
transactions = insert_default_test_transactions(sql_session, user, user2, product, product2)
@@ -436,83 +311,96 @@ def test_transaction_log_after_until_datetime_combined(sql_session: Session) ->
transaction_log(
sql_session,
after_time=second_transaction.time,
until_time=fifth_transaction.time,
before_time=fifth_transaction.time,
)
)
== 4
)
def test_transaction_log_after_until_transaction_illegal_order(sql_session: Session) -> None:
def test_transaction_log_before_after_transaction_id_combined(sql_session: Session) -> None:
user, user2, product, product2 = insert_test_data(sql_session)
transactions = insert_default_test_transactions(sql_session, user, user2, product, product2)
second_transaction = transactions[1]
fifth_transaction = transactions[4]
assert (
len(
transaction_log(
sql_session,
after_transaction_id=second_transaction.id,
before_transaction_id=fifth_transaction.id,
)
)
== 4
)
def test_transaction_log_before_date_after_transaction_id(sql_session: Session) -> None:
user, user2, product, product2 = insert_test_data(sql_session)
transactions = insert_default_test_transactions(sql_session, user, user2, product, product2)
second_transaction = transactions[1]
fifth_transaction = transactions[4]
assert (
len(
transaction_log(
sql_session,
before_time=fifth_transaction.time,
after_transaction_id=second_transaction.id,
)
)
== 4
)
def test_transaction_log_before_transaction_id_after_date(sql_session: Session) -> None:
user, user2, product, product2 = insert_test_data(sql_session)
transactions = insert_default_test_transactions(sql_session, user, user2, product, product2)
second_transaction = transactions[1]
fifth_transaction = transactions[4]
assert (
len(
transaction_log(
sql_session,
before_transaction_id=fifth_transaction.id,
after_time=second_transaction.time,
)
)
== 4
)
def test_transaction_log_after_product_and_user_not_allowed(sql_session: Session) -> None:
user, user2, product, product2 = insert_test_data(sql_session)
insert_default_test_transactions(sql_session, user, user2, product, product2)
with pytest.raises(ValueError):
transaction_log(
sql_session,
after_transaction=fifth_transaction,
until_transaction=second_transaction,
user=user,
product=product,
after_time=datetime(2023, 10, 1, 11, 0, 0),
)
def test_transaction_log_after_until_transaction_combined(sql_session: Session) -> None:
def test_transaction_log_after_datetime_and_transaction_id_not_allowed(
sql_session: Session,
) -> None:
user, user2, product, product2 = insert_test_data(sql_session)
transactions = insert_default_test_transactions(sql_session, user, user2, product, product2)
insert_default_test_transactions(sql_session, user, user2, product, product2)
second_transaction = transactions[1]
fifth_transaction = transactions[4]
assert (
len(
transaction_log(
sql_session,
after_transaction=second_transaction,
until_transaction=fifth_transaction,
)
with pytest.raises(ValueError):
transaction_log(
sql_session,
user=user,
after_time=datetime(2023, 10, 1, 11, 0, 0),
after_transaction_id=1,
)
== 4
)
def test_transaction_log_after_date_until_transaction(sql_session: Session) -> None:
user, user2, product, product2 = insert_test_data(sql_session)
transactions = insert_default_test_transactions(sql_session, user, user2, product, product2)
second_transaction = transactions[1]
fifth_transaction = transactions[4]
assert (
len(
transaction_log(
sql_session,
after_time=second_transaction.time,
until_transaction=fifth_transaction,
)
)
== 4
)
def test_transaction_log_after_transaction_until_date(sql_session: Session) -> None:
user, user2, product, product2 = insert_test_data(sql_session)
transactions = insert_default_test_transactions(sql_session, user, user2, product, product2)
second_transaction = transactions[1]
fifth_transaction = transactions[4]
assert (
len(
transaction_log(
sql_session,
after_transaction=second_transaction,
until_time=fifth_transaction.time,
)
)
== 4
)
def test_transaction_log_limit(sql_session: Session) -> None:
@@ -605,7 +493,7 @@ def test_transaction_log_combined_filter_user_datetime_transaction_type_limit(
sql_session,
user=user,
after_time=second_transaction.time,
until_time=sixth_transaction.time,
before_time=sixth_transaction.time,
transaction_type=[TransactionType.ADJUST_BALANCE, TransactionType.ADD_PRODUCT],
limit=2,
)
@@ -613,7 +501,7 @@ def test_transaction_log_combined_filter_user_datetime_transaction_type_limit(
assert len(result) == 2
def test_transaction_log_combined_filter_user_transaction_transaction_type_limit(
def test_transaction_log_combined_filter_user_transaction_id_transaction_type_limit(
sql_session: Session,
) -> None:
user, user2, product, product2 = insert_test_data(sql_session)
@@ -625,8 +513,8 @@ def test_transaction_log_combined_filter_user_transaction_transaction_type_limit
result = transaction_log(
sql_session,
user=user,
after_transaction=second_transaction,
until_transaction=sixth_transaction,
after_transaction_id=second_transaction.id,
before_transaction_id=sixth_transaction.id,
transaction_type=[TransactionType.ADJUST_BALANCE, TransactionType.ADD_PRODUCT],
limit=2,
)
@@ -647,7 +535,7 @@ def test_transaction_log_combined_filter_product_datetime_transaction_type_limit
sql_session,
product=product2,
after_time=second_transaction.time,
until_time=sixth_transaction.time,
before_time=sixth_transaction.time,
transaction_type=[TransactionType.BUY_PRODUCT, TransactionType.ADD_PRODUCT],
limit=2,
)
@@ -655,7 +543,7 @@ def test_transaction_log_combined_filter_product_datetime_transaction_type_limit
assert len(result) == 2
def test_transaction_log_combined_filter_product_transaction_transaction_type_limit(
def test_transaction_log_combined_filter_product_transaction_id_transaction_type_limit(
sql_session: Session,
) -> None:
user, user2, product, product2 = insert_test_data(sql_session)
@@ -667,8 +555,8 @@ def test_transaction_log_combined_filter_product_transaction_transaction_type_li
result = transaction_log(
sql_session,
product=product2,
after_transaction=second_transaction,
until_transaction=sixth_transaction,
after_transaction_id=second_transaction.id,
before_transaction_id=sixth_transaction.id,
transaction_type=[TransactionType.BUY_PRODUCT, TransactionType.ADD_PRODUCT],
limit=2,
)

View File

@@ -1,204 +0,0 @@
import pytest
from sqlalchemy import select
from sqlalchemy.orm import Session
from dibbler.models import Product, ProductCache, Transaction, User, UserCache
from dibbler.models.LastCacheTransaction import LastCacheTransaction
from dibbler.queries import update_cache
from tests.helpers import assert_id_order_similar_to_time_order, assign_times
def insert_test_data(sql_session: Session) -> tuple[User, User, Product, Product]:
user1 = User("Test User")
user2 = User("Another User")
product1 = Product("1234567890123", "Test Product 1")
product2 = Product("9876543210987", "Test Product 2")
sql_session.add_all([user1, user2, product1, product2])
sql_session.commit()
return user1, user2, product1, product2
def get_cache_entries(sql_session: Session) -> tuple[list[UserCache], list[ProductCache]]:
user_cache = sql_session.scalars(
select(UserCache)
.join(LastCacheTransaction, UserCache.last_cache_transaction_id == LastCacheTransaction.id)
.join(Transaction, LastCacheTransaction.transaction_id == Transaction.id)
.order_by(Transaction.time.asc(), UserCache.user_id)
).all()
product_cache = sql_session.scalars(
select(ProductCache)
.join(LastCacheTransaction, ProductCache.last_cache_transaction_id == LastCacheTransaction.id)
.join(Transaction, LastCacheTransaction.transaction_id == Transaction.id)
.order_by(Transaction.time.asc(), ProductCache.product_id)
).all()
return list(user_cache), list(product_cache)
def test_affected_update_cache_no_history(sql_session: Session) -> None:
insert_test_data(sql_session)
update_cache(sql_session)
def test_affected_update_cache_basic_history(sql_session: Session) -> None:
user1, user2, product1, product2 = insert_test_data(sql_session)
transactions = [
Transaction.add_product(
amount=10,
per_product=10,
user_id=user1.id,
product_id=product1.id,
product_count=1,
),
Transaction.add_product(
amount=20,
per_product=10,
user_id=user2.id,
product_id=product2.id,
product_count=2,
),
]
assign_times(transactions)
sql_session.add_all(transactions)
sql_session.commit()
assert_id_order_similar_to_time_order(transactions)
update_cache(sql_session)
user_cache = sql_session.scalars(select(UserCache).order_by(UserCache.user_id)).all()
product_cache = sql_session.scalars(
select(ProductCache).order_by(ProductCache.product_id)
).all()
assert user_cache[0].user_id == user1.id
assert user_cache[0].balance == 10
assert user_cache[1].user_id == user2.id
assert user_cache[1].balance == 20
assert product_cache[0].product_id == product1.id
assert product_cache[0].stock == 1
assert product_cache[0].price == 10
assert product_cache[1].product_id == product2.id
assert product_cache[1].stock == 2
assert product_cache[1].price == 10
def test_update_cache_multiple_times_no_changes(sql_session: Session) -> None:
user1, user2, product1, product2 = insert_test_data(sql_session)
transactions = [
Transaction.add_product(
amount=10,
per_product=10,
user_id=user1.id,
product_id=product1.id,
product_count=1,
),
Transaction.add_product(
amount=20,
per_product=10,
user_id=user2.id,
product_id=product2.id,
product_count=2,
),
]
assign_times(transactions)
sql_session.add_all(transactions)
sql_session.commit()
assert_id_order_similar_to_time_order(transactions)
update_cache(sql_session)
update_cache(sql_session)
user_cache, product_cache = get_cache_entries(sql_session)
assert user_cache[0].user_id == user1.id
assert user_cache[0].balance == 10
assert user_cache[1].user_id == user2.id
assert user_cache[1].balance == 20
def test_update_cache_multiple_times(sql_session: Session) -> None:
user1, user2, product1, product2 = insert_test_data(sql_session)
transactions = [
Transaction.add_product(
amount=10,
per_product=10,
user_id=user1.id,
product_id=product1.id,
product_count=1,
),
Transaction.add_product(
amount=20,
per_product=10,
user_id=user2.id,
product_id=product2.id,
product_count=2,
),
]
assign_times(transactions)
sql_session.add_all(transactions)
sql_session.commit()
assert_id_order_similar_to_time_order(transactions)
update_cache(sql_session)
transactions_more = [
Transaction.add_product(
amount=30,
per_product=10,
user_id=user1.id,
product_id=product1.id,
product_count=3,
),
Transaction.buy_product(
user_id=user1.id,
product_id=product1.id,
product_count=1,
),
]
assign_times(transactions_more, start_time=transactions[-1].time)
sql_session.add_all(transactions_more)
sql_session.commit()
assert_id_order_similar_to_time_order(transactions_more)
update_cache(sql_session)
user_cache, product_cache = get_cache_entries(sql_session)
assert user_cache[0].user_id == user1.id
assert user_cache[0].balance == 10
assert user_cache[1].user_id == user2.id
assert user_cache[1].balance == 20
assert product_cache[0].product_id == product1.id
assert product_cache[0].stock == 1
assert product_cache[0].price == 10
assert product_cache[1].product_id == product2.id
assert product_cache[1].stock == 2
assert product_cache[1].price == 10
assert user_cache[2].user_id == user1.id
assert user_cache[2].balance == 10 + 30 - 10
assert product_cache[2].product_id == product1.id
assert product_cache[2].stock == 1 + 3 - 1
assert product_cache[2].price == 10

View File

@@ -1,18 +1,13 @@
import math
from datetime import datetime, timedelta
from datetime import datetime
from pprint import pprint
import pytest
from sqlalchemy.orm import Session
from dibbler.models import Product, Transaction, User
from dibbler.models.Transaction import (
DEFAULT_INTEREST_RATE_PERCENT,
DEFAULT_PENALTY_MULTIPLIER_PERCENT,
)
from dibbler.models.Transaction import DEFAULT_PENALTY_MULTIPLIER_PERCENTAGE
from dibbler.queries import joint_buy_product, user_balance, user_balance_log
from dibbler.queries.user_balance import _joint_transaction_query, _non_joint_transaction_query
from tests.helpers import assert_id_order_similar_to_time_order, assign_times
# TODO: see if we can use pytest_runtest_makereport to print the "user_balance_log"s
# only on failures instead of inlining it in every test function
@@ -30,189 +25,6 @@ def insert_test_data(sql_session: Session) -> tuple[User, User, User, Product]:
return user, user2, user3, product
# NOTE: see economics spec
def _product_cost(
per_product: int,
product_count: int,
interest_rate_percent: int = DEFAULT_INTEREST_RATE_PERCENT,
apply_penalty: bool = False,
penalty_multiplier_percent: int = DEFAULT_PENALTY_MULTIPLIER_PERCENT,
joint_shares: int = 1,
joint_total_shares: int = 1,
) -> int:
base_cost: float = per_product * product_count * joint_shares / joint_total_shares
added_interest: float = base_cost * ((interest_rate_percent - 100) / 100)
penalty: float = 0.0
if apply_penalty:
penalty: float = base_cost * ((penalty_multiplier_percent - 100) / 100)
total_cost: int = math.ceil(base_cost + added_interest + penalty)
return total_cost
def test_non_joint_transaction_query(sql_session) -> None:
user1, user2, user3, product = insert_test_data(sql_session)
transactions = [
Transaction.adjust_balance(
user_id=user1.id,
amount=100,
),
Transaction.adjust_balance(
user_id=user2.id,
amount=50,
),
Transaction.add_product(
user_id=user2.id,
amount=70,
product_id=product.id,
product_count=3,
per_product=30,
),
Transaction.transfer(
user_id=user1.id,
transfer_user_id=user2.id,
amount=50,
),
Transaction.transfer(
user_id=user2.id,
transfer_user_id=user3.id,
amount=30,
),
]
assign_times(transactions)
sql_session.add_all(transactions)
sql_session.commit()
assert_id_order_similar_to_time_order(transactions)
t = transactions
result = {
row[0]
for row in sql_session.execute(
_non_joint_transaction_query(
user_id=user1.id,
use_cache=False,
),
).all()
}
assert result == {t[0].id, t[3].id}
result = {
row[0]
for row in sql_session.execute(
_non_joint_transaction_query(
user_id=user2.id,
use_cache=False,
),
).all()
}
assert result == {
t[1].id,
t[2].id,
t[3].id,
t[4].id,
}
result = {
row[0]
for row in sql_session.execute(
_non_joint_transaction_query(
user_id=user3.id,
use_cache=False,
),
).all()
}
assert result == {t[4].id}
def test_joint_transaction_query(sql_session: Session) -> None:
user1, user2, user3, product = insert_test_data(sql_session)
j1 = joint_buy_product(
sql_session,
product=product,
product_count=3,
instigator=user1,
users=[user1, user2],
)
j2 = joint_buy_product(
sql_session,
product=product,
product_count=2,
instigator=user1,
users=[user1, user1, user2],
time=j1[-1].time + timedelta(minutes=1),
)
j3 = joint_buy_product(
sql_session,
product=product,
product_count=2,
instigator=user1,
users=[user1, user3, user3],
time=j2[-1].time + timedelta(minutes=1),
)
j4 = joint_buy_product(
sql_session,
product=product,
product_count=2,
instigator=user2,
users=[user2, user3, user3],
time=j3[-1].time + timedelta(minutes=1),
)
assert_id_order_similar_to_time_order(j1 + j2 + j3 + j4)
result = set(
sql_session.execute(
_joint_transaction_query(
user_id=user1.id,
use_cache=False,
),
).all(),
)
assert result == {
(j1[0].id, 1, 2),
(j2[0].id, 2, 3),
(j3[0].id, 1, 3),
}
result = set(
sql_session.execute(
_joint_transaction_query(
user_id=user2.id,
use_cache=False,
),
).all(),
)
assert result == {
(j1[0].id, 1, 2),
(j2[0].id, 1, 3),
(j4[0].id, 1, 3),
}
result = set(
sql_session.execute(
_joint_transaction_query(
user_id=user3.id,
use_cache=False,
),
).all(),
)
assert result == {
(j3[0].id, 2, 3),
(j4[0].id, 2, 3),
}
def test_user_balance_no_transactions(sql_session: Session) -> None:
user, *_ = insert_test_data(sql_session)
@@ -228,10 +40,12 @@ def test_user_balance_basic_history(sql_session: Session) -> None:
transactions = [
Transaction.adjust_balance(
time=datetime(2023, 10, 1, 10, 0, 0),
user_id=user.id,
amount=100,
),
Transaction.add_product(
time=datetime(2023, 10, 1, 10, 0, 1),
user_id=user.id,
product_id=product.id,
amount=27,
@@ -240,13 +54,9 @@ def test_user_balance_basic_history(sql_session: Session) -> None:
),
]
assign_times(transactions)
sql_session.add_all(transactions)
sql_session.commit()
assert_id_order_similar_to_time_order(transactions)
pprint(user_balance_log(sql_session, user))
balance = user_balance(sql_session, user)
@@ -255,32 +65,31 @@ def test_user_balance_basic_history(sql_session: Session) -> None:
def test_user_balance_with_transfers(sql_session: Session) -> None:
user1, user2, _, _ = insert_test_data(sql_session)
user1, user2, _, product = insert_test_data(sql_session)
transactions = [
Transaction.adjust_balance(
time=datetime(2023, 10, 1, 10, 0, 0),
user_id=user1.id,
amount=100,
),
Transaction.transfer(
time=datetime(2023, 10, 1, 10, 0, 1),
user_id=user1.id,
transfer_user_id=user2.id,
amount=50,
),
Transaction.transfer(
time=datetime(2023, 10, 1, 10, 0, 2),
user_id=user2.id,
transfer_user_id=user1.id,
amount=30,
),
]
assign_times(transactions)
sql_session.add_all(transactions)
sql_session.commit()
assert_id_order_similar_to_time_order(transactions)
pprint(user_balance_log(sql_session, user1))
user1_balance = user_balance(sql_session, user1)
@@ -292,11 +101,16 @@ def test_user_balance_with_transfers(sql_session: Session) -> None:
assert user2_balance == 50 - 30
@pytest.mark.skip(reason="Not yet implemented")
def test_user_balance_complex_history(sql_session: Session) -> None: ...
def test_user_balance_penalty(sql_session: Session) -> None:
user, _, _, product = insert_test_data(sql_session)
transactions = [
Transaction.add_product(
time=datetime(2023, 10, 1, 10, 0, 0),
user_id=user.id,
product_id=product.id,
amount=27,
@@ -304,27 +118,26 @@ def test_user_balance_penalty(sql_session: Session) -> None:
product_count=1,
),
Transaction.adjust_balance(
time=datetime(2023, 10, 1, 11, 0, 0),
user_id=user.id,
amount=-200,
),
# Penalized, pays 2x the price (default penalty)
Transaction.buy_product(
time=datetime(2023, 10, 1, 12, 0, 0),
user_id=user.id,
product_id=product.id,
product_count=1,
),
]
assign_times(transactions)
sql_session.add_all(transactions)
sql_session.commit()
assert_id_order_similar_to_time_order(transactions)
pprint(user_balance_log(sql_session, user))
assert user_balance(sql_session, user) == 27 - 200 - _product_cost(27, 1, apply_penalty=True)
assert user_balance(sql_session, user) == 27 - 200 - (
27 * (DEFAULT_PENALTY_MULTIPLIER_PERCENTAGE // 100)
)
def test_user_balance_changing_penalty(sql_session: Session) -> None:
@@ -332,6 +145,7 @@ def test_user_balance_changing_penalty(sql_session: Session) -> None:
transactions = [
Transaction.add_product(
time=datetime(2023, 10, 1, 10, 0, 0),
user_id=user.id,
product_id=product.id,
amount=27,
@@ -339,43 +153,40 @@ def test_user_balance_changing_penalty(sql_session: Session) -> None:
product_count=1,
),
Transaction.adjust_balance(
time=datetime(2023, 10, 1, 11, 0, 0),
user_id=user.id,
amount=-200,
),
# Penalized, pays 2x the price (default penalty)
Transaction.buy_product(
time=datetime(2023, 10, 1, 12, 0, 0),
user_id=user.id,
product_id=product.id,
product_count=1,
),
Transaction.adjust_penalty(
time=datetime(2023, 10, 1, 13, 0, 0),
user_id=user.id,
penalty_multiplier_percent=300,
penalty_threshold=-100,
),
# Penalized, pays 3x the price
Transaction.buy_product(
time=datetime(2023, 10, 1, 14, 0, 0),
user_id=user.id,
product_id=product.id,
product_count=1,
),
]
assign_times(transactions)
sql_session.add_all(transactions)
sql_session.commit()
assert_id_order_similar_to_time_order(transactions)
pprint(user_balance_log(sql_session, user))
assert user_balance(sql_session, user) == (
27
- 200
- _product_cost(27, 1, apply_penalty=True)
- _product_cost(27, 1, apply_penalty=True, penalty_multiplier_percent=300)
)
assert user_balance(sql_session, user) == 27 - 200 - (
27 * (DEFAULT_PENALTY_MULTIPLIER_PERCENTAGE // 100)
) - (27 * 3)
def test_user_balance_interest(sql_session: Session) -> None:
@@ -383,6 +194,7 @@ def test_user_balance_interest(sql_session: Session) -> None:
transactions = [
Transaction.add_product(
time=datetime(2023, 10, 1, 10, 0, 0),
user_id=user.id,
product_id=product.id,
amount=27,
@@ -390,26 +202,23 @@ def test_user_balance_interest(sql_session: Session) -> None:
product_count=1,
),
Transaction.adjust_interest(
time=datetime(2023, 10, 1, 11, 0, 0),
user_id=user.id,
interest_rate_percent=110,
),
Transaction.buy_product(
time=datetime(2023, 10, 1, 12, 0, 0),
user_id=user.id,
product_id=product.id,
product_count=1,
),
]
assign_times(transactions)
sql_session.add_all(transactions)
sql_session.commit()
assert_id_order_similar_to_time_order(transactions)
pprint(user_balance_log(sql_session, user))
assert user_balance(sql_session, user) == 27 - _product_cost(27, 1, interest_rate_percent=110)
assert user_balance(sql_session, user) == 27 - math.ceil(27 * 1.1)
def test_user_balance_changing_interest(sql_session: Session) -> None:
@@ -417,6 +226,7 @@ def test_user_balance_changing_interest(sql_session: Session) -> None:
transactions = [
Transaction.add_product(
time=datetime(2023, 10, 1, 10, 0, 0),
user_id=user.id,
product_id=product.id,
amount=27 * 3,
@@ -424,41 +234,36 @@ def test_user_balance_changing_interest(sql_session: Session) -> None:
product_count=3,
),
Transaction.adjust_interest(
time=datetime(2023, 10, 1, 11, 0, 0),
user_id=user.id,
interest_rate_percent=110,
),
# Pays 1.1x the price
Transaction.buy_product(
time=datetime(2023, 10, 1, 12, 0, 0),
user_id=user.id,
product_id=product.id,
product_count=1,
),
Transaction.adjust_interest(
time=datetime(2023, 10, 1, 13, 0, 0),
user_id=user.id,
interest_rate_percent=120,
),
# Pays 1.2x the price
Transaction.buy_product(
time=datetime(2023, 10, 1, 14, 0, 0),
user_id=user.id,
product_id=product.id,
product_count=1,
),
]
assign_times(transactions)
sql_session.add_all(transactions)
sql_session.commit()
assert_id_order_similar_to_time_order(transactions)
pprint(user_balance_log(sql_session, user))
assert user_balance(sql_session, user) == (
27 * 3
- _product_cost(27, 1, interest_rate_percent=110)
- _product_cost(27, 1, interest_rate_percent=120)
)
assert user_balance(sql_session, user) == 27 * 3 - math.ceil(27 * 1.1) - math.ceil(27 * 1.2)
def test_user_balance_penalty_interest_combined(sql_session: Session) -> None:
@@ -466,6 +271,7 @@ def test_user_balance_penalty_interest_combined(sql_session: Session) -> None:
transactions = [
Transaction.add_product(
time=datetime(2023, 10, 1, 10, 0, 0),
user_id=user.id,
product_id=product.id,
amount=27,
@@ -473,40 +279,31 @@ def test_user_balance_penalty_interest_combined(sql_session: Session) -> None:
product_count=1,
),
Transaction.adjust_interest(
time=datetime(2023, 10, 1, 11, 0, 0),
user_id=user.id,
interest_rate_percent=110,
),
Transaction.adjust_balance(
time=datetime(2023, 10, 1, 12, 0, 0),
user_id=user.id,
amount=-200,
),
# Penalized, pays 2x the price (default penalty)
# Pays 1.1x the price
Transaction.buy_product(
time=datetime(2023, 10, 1, 13, 0, 0),
user_id=user.id,
product_id=product.id,
product_count=1,
),
]
assign_times(transactions)
sql_session.add_all(transactions)
sql_session.commit()
assert_id_order_similar_to_time_order(transactions)
pprint(user_balance_log(sql_session, user))
assert user_balance(sql_session, user) == (
27
- 200
- _product_cost(
27,
1,
interest_rate_percent=110,
apply_penalty=True,
)
27 - 200 - math.ceil(27 * (DEFAULT_PENALTY_MULTIPLIER_PERCENTAGE // 100) * 1.1)
)
@@ -532,20 +329,11 @@ def test_user_balance_joint_transaction_single_user(sql_session: Session) -> Non
users=[user],
product=product,
product_count=2,
time=transactions[-1].time + timedelta(minutes=1),
)
pprint(user_balance_log(sql_session, user))
assert user_balance(sql_session, user) == (
(27 * 3)
- _product_cost(
27,
2,
joint_shares=1,
joint_total_shares=1,
)
)
assert user_balance(sql_session, user) == (27 * 3) - (27 * 2)
def test_user_balance_joint_transactions_multiple_users(sql_session: Session) -> None:
@@ -570,20 +358,11 @@ def test_user_balance_joint_transactions_multiple_users(sql_session: Session) ->
users=[user, user2, user3],
product=product,
product_count=2,
time=transactions[-1].time + timedelta(minutes=1),
)
pprint(user_balance_log(sql_session, user))
assert user_balance(sql_session, user) == (
(27 * 3)
- _product_cost(
27,
2,
joint_shares=1,
joint_total_shares=3,
)
)
assert user_balance(sql_session, user) == (27 * 3) - math.ceil((27 * 2) / 3)
def test_user_balance_joint_transactions_multiple_times_self(sql_session: Session) -> None:
@@ -608,20 +387,11 @@ def test_user_balance_joint_transactions_multiple_times_self(sql_session: Sessio
users=[user, user, user2],
product=product,
product_count=2,
time=transactions[-1].time + timedelta(minutes=1),
)
pprint(user_balance_log(sql_session, user))
assert user_balance(sql_session, user) == (
(27 * 3)
- _product_cost(
27,
2,
joint_shares=2,
joint_total_shares=3,
)
)
assert user_balance(sql_session, user) == (27 * 3) - math.ceil((27 * 2) * (2 / 3))
def test_user_balance_joint_transactions_interest(sql_session: Session) -> None:
@@ -629,6 +399,7 @@ def test_user_balance_joint_transactions_interest(sql_session: Session) -> None:
transactions = [
Transaction.add_product(
time=datetime(2023, 10, 1, 10, 0, 0),
user_id=user.id,
product_id=product.id,
amount=27 * 3,
@@ -636,39 +407,25 @@ def test_user_balance_joint_transactions_interest(sql_session: Session) -> None:
product_count=3,
),
Transaction.adjust_interest(
time=datetime(2023, 10, 1, 11, 0, 0),
user_id=user.id,
interest_rate_percent=110,
),
]
assign_times(transactions)
sql_session.add_all(transactions)
sql_session.commit()
transactions += joint_buy_product(
joint_buy_product(
sql_session,
instigator=user,
users=[user, user2],
product=product,
product_count=2,
time=transactions[-1].time + timedelta(minutes=1),
)
assert_id_order_similar_to_time_order(transactions)
pprint(user_balance_log(sql_session, user))
assert user_balance(sql_session, user) == (
(27 * 3)
- _product_cost(
27,
2,
joint_shares=1,
joint_total_shares=2,
interest_rate_percent=110,
)
)
assert user_balance(sql_session, user) == (27 * 3) - math.ceil(math.ceil(27 * 2 / 2) * 1.1)
def test_user_balance_joint_transactions_changing_interest(sql_session: Session) -> None:
@@ -676,6 +433,7 @@ def test_user_balance_joint_transactions_changing_interest(sql_session: Session)
transactions = [
Transaction.add_product(
time=datetime(2023, 10, 1, 10, 0, 0),
user_id=user.id,
product_id=product.id,
amount=27 * 4,
@@ -684,29 +442,26 @@ def test_user_balance_joint_transactions_changing_interest(sql_session: Session)
),
# Pays 1.1x the price
Transaction.adjust_interest(
time=datetime(2023, 10, 1, 11, 0, 0),
user_id=user.id,
interest_rate_percent=110,
),
]
assign_times(transactions)
sql_session.add_all(transactions)
sql_session.commit()
transactions += joint_buy_product(
joint_buy_product(
sql_session,
instigator=user,
users=[user, user2],
product=product,
product_count=2,
time=transactions[-1].time + timedelta(minutes=15),
)
transactions += [
transactions = [
# Pays 1.2x the price
Transaction.adjust_interest(
time=transactions[-1].time + timedelta(minutes=15),
time=datetime(2023, 10, 1, 12, 0, 0),
user_id=user.id,
interest_rate_percent=120,
)
@@ -714,35 +469,18 @@ def test_user_balance_joint_transactions_changing_interest(sql_session: Session)
sql_session.add_all(transactions)
sql_session.commit()
transactions += joint_buy_product(
joint_buy_product(
sql_session,
instigator=user,
users=[user, user2],
product=product,
product_count=1,
time=transactions[-1].time + timedelta(minutes=15),
)
assert_id_order_similar_to_time_order(transactions)
pprint(user_balance_log(sql_session, user))
assert user_balance(sql_session, user) == (
(27 * 4)
- _product_cost(
27,
2,
joint_shares=1,
joint_total_shares=2,
interest_rate_percent=110,
)
- _product_cost(
27,
1,
joint_shares=1,
joint_total_shares=2,
interest_rate_percent=120,
)
(27 * 4) - math.ceil(math.ceil(27 * 2 / 2) * 1.1) - math.ceil(math.ceil(27 * 1 / 2) * 1.2)
)
@@ -751,6 +489,7 @@ def test_user_balance_joint_transactions_penalty(sql_session: Session) -> None:
transactions = [
Transaction.add_product(
time=datetime(2023, 10, 1, 10, 0, 0),
user_id=user.id,
product_id=product.id,
amount=27 * 3,
@@ -758,258 +497,43 @@ def test_user_balance_joint_transactions_penalty(sql_session: Session) -> None:
product_count=3,
),
Transaction.adjust_balance(
time=datetime(2023, 10, 1, 11, 0, 0),
user_id=user.id,
amount=-200,
),
]
assign_times(transactions)
sql_session.add_all(transactions)
sql_session.commit()
transactions += joint_buy_product(
joint_buy_product(
sql_session,
instigator=user,
users=[user, user2],
product=product,
product_count=2,
time=transactions[-1].time + timedelta(minutes=15),
)
assert_id_order_similar_to_time_order(transactions)
pprint(user_balance_log(sql_session, user))
assert user_balance(sql_session, user) == (
(27 * 3)
- 200
- _product_cost(
27,
2,
joint_shares=1,
joint_total_shares=2,
apply_penalty=True,
)
- math.ceil(math.ceil(27 * 2 / 2) * (DEFAULT_PENALTY_MULTIPLIER_PERCENTAGE // 100))
)
def test_user_balance_joint_transactions_changing_penalty(sql_session: Session) -> None:
user, user2, _, product = insert_test_data(sql_session)
transactions = [
Transaction.add_product(
user_id=user.id,
product_id=product.id,
amount=27 * 3,
per_product=27,
product_count=3,
),
Transaction.adjust_balance(
user_id=user.id,
amount=-200,
),
]
assign_times(transactions)
sql_session.add_all(transactions)
sql_session.commit()
transactions += joint_buy_product(
sql_session,
instigator=user,
users=[user, user2],
product=product,
product_count=2,
time=transactions[-1].time + timedelta(minutes=15),
)
transactions += [
Transaction.adjust_penalty(
time=transactions[-1].time + timedelta(minutes=30),
user_id=user.id,
penalty_multiplier_percent=300,
penalty_threshold=-100,
)
]
sql_session.add_all(transactions)
sql_session.commit()
transactions += joint_buy_product(
sql_session,
instigator=user,
users=[user, user2],
product=product,
product_count=1,
time=transactions[-1].time + timedelta(minutes=45),
)
assert_id_order_similar_to_time_order(transactions)
pprint(user_balance_log(sql_session, user))
assert user_balance(sql_session, user) == (
(27 * 3)
- 200
- _product_cost(
27,
2,
joint_shares=1,
joint_total_shares=2,
apply_penalty=True,
)
- _product_cost(
27,
1,
joint_shares=1,
joint_total_shares=2,
apply_penalty=True,
penalty_multiplier_percent=300,
)
)
@pytest.mark.skip(reason="Not yet implemented")
def test_user_balance_joint_transactions_changing_penalty(sql_session: Session) -> None: ...
@pytest.mark.skip(reason="Not yet implemented")
def test_user_balance_joint_transactions_penalty_interest_combined(
sql_session: Session,
) -> None:
user, user2, _, product = insert_test_data(sql_session)
transactions = [
Transaction.add_product(
user_id=user.id,
product_id=product.id,
amount=27 * 3,
per_product=27,
product_count=3,
),
Transaction.adjust_interest(
user_id=user.id,
interest_rate_percent=110,
),
Transaction.adjust_balance(
user_id=user.id,
amount=-200,
),
]
assign_times(transactions)
sql_session.add_all(transactions)
sql_session.commit()
transactions += joint_buy_product(
sql_session,
instigator=user,
users=[user, user2],
product=product,
product_count=2,
time=transactions[-1].time + timedelta(minutes=15),
)
pprint(user_balance_log(sql_session, user))
assert user_balance(sql_session, user) == (
(27 * 3)
- 200
- _product_cost(
27,
2,
joint_shares=1,
joint_total_shares=2,
interest_rate_percent=110,
apply_penalty=True,
)
)
) -> None: ...
def test_user_balance_until_time(sql_session: Session) -> None:
user, _, _, product = insert_test_data(sql_session)
transactions = [
Transaction.adjust_balance(
user_id=user.id,
amount=100,
),
Transaction.add_product(
user_id=user.id,
product_id=product.id,
amount=27,
per_product=27,
product_count=1,
),
Transaction.adjust_balance(
user_id=user.id,
amount=50,
),
]
assign_times(transactions)
sql_session.add_all(transactions)
sql_session.commit()
pprint(
user_balance_log(
sql_session,
user,
until_time=transactions[1].time + timedelta(seconds=30),
)
)
balance = user_balance(
sql_session,
user,
until_time=transactions[1].time + timedelta(seconds=30),
)
assert balance == 100 + 27
def test_user_balance_until_transaction(sql_session: Session) -> None:
user, _, _, product = insert_test_data(sql_session)
transactions = [
Transaction.adjust_balance(
user_id=user.id,
amount=100,
),
Transaction.add_product(
user_id=user.id,
product_id=product.id,
amount=27,
per_product=27,
product_count=1,
),
Transaction.adjust_balance(
user_id=user.id,
amount=50,
),
]
assign_times(transactions)
sql_session.add_all(transactions)
sql_session.commit()
until_transaction = transactions[1]
pprint(
user_balance_log(
sql_session,
user,
until_transaction=until_transaction,
)
)
balance = user_balance(
sql_session,
user,
until_transaction=until_transaction,
)
assert balance == 100 + 27
@pytest.mark.skip(reason="Not yet implemented")
def test_user_balance_until(sql_session: Session) -> None: ...
@pytest.mark.skip(reason="Not yet implemented")

295
uv.lock generated
View File

@@ -133,89 +133,89 @@ wheels = [
[[package]]
name = "coverage"
version = "7.13.0"
version = "7.12.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b6/45/2c665ca77ec32ad67e25c77daf1cee28ee4558f3bc571cdbaf88a00b9f23/coverage-7.13.0.tar.gz", hash = "sha256:a394aa27f2d7ff9bc04cf703817773a59ad6dfbd577032e690f961d2460ee936", size = 820905, upload-time = "2025-12-08T13:14:38.055Z" }
sdist = { url = "https://files.pythonhosted.org/packages/89/26/4a96807b193b011588099c3b5c89fbb05294e5b90e71018e065465f34eb6/coverage-7.12.0.tar.gz", hash = "sha256:fc11e0a4e372cb5f282f16ef90d4a585034050ccda536451901abfb19a57f40c", size = 819341, upload-time = "2025-11-18T13:34:20.766Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/dc/888bf90d8b1c3d0b4020a40e52b9f80957d75785931ec66c7dfaccc11c7d/coverage-7.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0dfa3855031070058add1a59fdfda0192fd3e8f97e7c81de0596c145dea51820", size = 218104, upload-time = "2025-12-08T13:12:33.333Z" },
{ url = "https://files.pythonhosted.org/packages/8d/ea/069d51372ad9c380214e86717e40d1a743713a2af191cfba30a0911b0a4a/coverage-7.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4fdb6f54f38e334db97f72fa0c701e66d8479af0bc3f9bfb5b90f1c30f54500f", size = 218606, upload-time = "2025-12-08T13:12:34.498Z" },
{ url = "https://files.pythonhosted.org/packages/68/09/77b1c3a66c2aa91141b6c4471af98e5b1ed9b9e6d17255da5eb7992299e3/coverage-7.13.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7e442c013447d1d8d195be62852270b78b6e255b79b8675bad8479641e21fd96", size = 248999, upload-time = "2025-12-08T13:12:36.02Z" },
{ url = "https://files.pythonhosted.org/packages/0a/32/2e2f96e9d5691eaf1181d9040f850b8b7ce165ea10810fd8e2afa534cef7/coverage-7.13.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ed5630d946859de835a85e9a43b721123a8a44ec26e2830b296d478c7fd4259", size = 250925, upload-time = "2025-12-08T13:12:37.221Z" },
{ url = "https://files.pythonhosted.org/packages/7b/45/b88ddac1d7978859b9a39a8a50ab323186148f1d64bc068f86fc77706321/coverage-7.13.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f15a931a668e58087bc39d05d2b4bf4b14ff2875b49c994bbdb1c2217a8daeb", size = 253032, upload-time = "2025-12-08T13:12:38.763Z" },
{ url = "https://files.pythonhosted.org/packages/71/cb/e15513f94c69d4820a34b6bf3d2b1f9f8755fa6021be97c7065442d7d653/coverage-7.13.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30a3a201a127ea57f7e14ba43c93c9c4be8b7d17a26e03bb49e6966d019eede9", size = 249134, upload-time = "2025-12-08T13:12:40.382Z" },
{ url = "https://files.pythonhosted.org/packages/09/61/d960ff7dc9e902af3310ce632a875aaa7860f36d2bc8fc8b37ee7c1b82a5/coverage-7.13.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7a485ff48fbd231efa32d58f479befce52dcb6bfb2a88bb7bf9a0b89b1bc8030", size = 250731, upload-time = "2025-12-08T13:12:41.992Z" },
{ url = "https://files.pythonhosted.org/packages/98/34/c7c72821794afc7c7c2da1db8f00c2c98353078aa7fb6b5ff36aac834b52/coverage-7.13.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:22486cdafba4f9e471c816a2a5745337742a617fef68e890d8baf9f3036d7833", size = 248795, upload-time = "2025-12-08T13:12:43.331Z" },
{ url = "https://files.pythonhosted.org/packages/0a/5b/e0f07107987a43b2def9aa041c614ddb38064cbf294a71ef8c67d43a0cdd/coverage-7.13.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:263c3dbccc78e2e331e59e90115941b5f53e85cfcc6b3b2fbff1fd4e3d2c6ea8", size = 248514, upload-time = "2025-12-08T13:12:44.546Z" },
{ url = "https://files.pythonhosted.org/packages/71/c2/c949c5d3b5e9fc6dd79e1b73cdb86a59ef14f3709b1d72bf7668ae12e000/coverage-7.13.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e5330fa0cc1f5c3c4c3bb8e101b742025933e7848989370a1d4c8c5e401ea753", size = 249424, upload-time = "2025-12-08T13:12:45.759Z" },
{ url = "https://files.pythonhosted.org/packages/11/f1/bbc009abd6537cec0dffb2cc08c17a7f03de74c970e6302db4342a6e05af/coverage-7.13.0-cp311-cp311-win32.whl", hash = "sha256:0f4872f5d6c54419c94c25dd6ae1d015deeb337d06e448cd890a1e89a8ee7f3b", size = 220597, upload-time = "2025-12-08T13:12:47.378Z" },
{ url = "https://files.pythonhosted.org/packages/c4/f6/d9977f2fb51c10fbaed0718ce3d0a8541185290b981f73b1d27276c12d91/coverage-7.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:51a202e0f80f241ccb68e3e26e19ab5b3bf0f813314f2c967642f13ebcf1ddfe", size = 221536, upload-time = "2025-12-08T13:12:48.7Z" },
{ url = "https://files.pythonhosted.org/packages/be/ad/3fcf43fd96fb43e337a3073dea63ff148dcc5c41ba7a14d4c7d34efb2216/coverage-7.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:d2a9d7f1c11487b1c69367ab3ac2d81b9b3721f097aa409a3191c3e90f8f3dd7", size = 220206, upload-time = "2025-12-08T13:12:50.365Z" },
{ url = "https://files.pythonhosted.org/packages/9b/f1/2619559f17f31ba00fc40908efd1fbf1d0a5536eb75dc8341e7d660a08de/coverage-7.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0b3d67d31383c4c68e19a88e28fc4c2e29517580f1b0ebec4a069d502ce1e0bf", size = 218274, upload-time = "2025-12-08T13:12:52.095Z" },
{ url = "https://files.pythonhosted.org/packages/2b/11/30d71ae5d6e949ff93b2a79a2c1b4822e00423116c5c6edfaeef37301396/coverage-7.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:581f086833d24a22c89ae0fe2142cfaa1c92c930adf637ddf122d55083fb5a0f", size = 218638, upload-time = "2025-12-08T13:12:53.418Z" },
{ url = "https://files.pythonhosted.org/packages/79/c2/fce80fc6ded8d77e53207489d6065d0fed75db8951457f9213776615e0f5/coverage-7.13.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0a3a30f0e257df382f5f9534d4ce3d4cf06eafaf5192beb1a7bd066cb10e78fb", size = 250129, upload-time = "2025-12-08T13:12:54.744Z" },
{ url = "https://files.pythonhosted.org/packages/5b/b6/51b5d1eb6fcbb9a1d5d6984e26cbe09018475c2922d554fd724dd0f056ee/coverage-7.13.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:583221913fbc8f53b88c42e8dbb8fca1d0f2e597cb190ce45916662b8b9d9621", size = 252885, upload-time = "2025-12-08T13:12:56.401Z" },
{ url = "https://files.pythonhosted.org/packages/0d/f8/972a5affea41de798691ab15d023d3530f9f56a72e12e243f35031846ff7/coverage-7.13.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f5d9bd30756fff3e7216491a0d6d520c448d5124d3d8e8f56446d6412499e74", size = 253974, upload-time = "2025-12-08T13:12:57.718Z" },
{ url = "https://files.pythonhosted.org/packages/8a/56/116513aee860b2c7968aa3506b0f59b22a959261d1dbf3aea7b4450a7520/coverage-7.13.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a23e5a1f8b982d56fa64f8e442e037f6ce29322f1f9e6c2344cd9e9f4407ee57", size = 250538, upload-time = "2025-12-08T13:12:59.254Z" },
{ url = "https://files.pythonhosted.org/packages/d6/75/074476d64248fbadf16dfafbf93fdcede389ec821f74ca858d7c87d2a98c/coverage-7.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9b01c22bc74a7fb44066aaf765224c0d933ddf1f5047d6cdfe4795504a4493f8", size = 251912, upload-time = "2025-12-08T13:13:00.604Z" },
{ url = "https://files.pythonhosted.org/packages/f2/d2/aa4f8acd1f7c06024705c12609d8698c51b27e4d635d717cd1934c9668e2/coverage-7.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:898cce66d0836973f48dda4e3514d863d70142bdf6dfab932b9b6a90ea5b222d", size = 250054, upload-time = "2025-12-08T13:13:01.892Z" },
{ url = "https://files.pythonhosted.org/packages/19/98/8df9e1af6a493b03694a1e8070e024e7d2cdc77adedc225a35e616d505de/coverage-7.13.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:3ab483ea0e251b5790c2aac03acde31bff0c736bf8a86829b89382b407cd1c3b", size = 249619, upload-time = "2025-12-08T13:13:03.236Z" },
{ url = "https://files.pythonhosted.org/packages/d8/71/f8679231f3353018ca66ef647fa6fe7b77e6bff7845be54ab84f86233363/coverage-7.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d84e91521c5e4cb6602fe11ece3e1de03b2760e14ae4fcf1a4b56fa3c801fcd", size = 251496, upload-time = "2025-12-08T13:13:04.511Z" },
{ url = "https://files.pythonhosted.org/packages/04/86/9cb406388034eaf3c606c22094edbbb82eea1fa9d20c0e9efadff20d0733/coverage-7.13.0-cp312-cp312-win32.whl", hash = "sha256:193c3887285eec1dbdb3f2bd7fbc351d570ca9c02ca756c3afbc71b3c98af6ef", size = 220808, upload-time = "2025-12-08T13:13:06.422Z" },
{ url = "https://files.pythonhosted.org/packages/1c/59/af483673df6455795daf5f447c2f81a3d2fcfc893a22b8ace983791f6f34/coverage-7.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:4f3e223b2b2db5e0db0c2b97286aba0036ca000f06aca9b12112eaa9af3d92ae", size = 221616, upload-time = "2025-12-08T13:13:07.95Z" },
{ url = "https://files.pythonhosted.org/packages/64/b0/959d582572b30a6830398c60dd419c1965ca4b5fb38ac6b7093a0d50ca8d/coverage-7.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:086cede306d96202e15a4b77ace8472e39d9f4e5f9fd92dd4fecdfb2313b2080", size = 220261, upload-time = "2025-12-08T13:13:09.581Z" },
{ url = "https://files.pythonhosted.org/packages/7c/cc/bce226595eb3bf7d13ccffe154c3c487a22222d87ff018525ab4dd2e9542/coverage-7.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:28ee1c96109974af104028a8ef57cec21447d42d0e937c0275329272e370ebcf", size = 218297, upload-time = "2025-12-08T13:13:10.977Z" },
{ url = "https://files.pythonhosted.org/packages/3b/9f/73c4d34600aae03447dff3d7ad1d0ac649856bfb87d1ca7d681cfc913f9e/coverage-7.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d1e97353dcc5587b85986cda4ff3ec98081d7e84dd95e8b2a6d59820f0545f8a", size = 218673, upload-time = "2025-12-08T13:13:12.562Z" },
{ url = "https://files.pythonhosted.org/packages/63/ab/8fa097db361a1e8586535ae5073559e6229596b3489ec3ef2f5b38df8cb2/coverage-7.13.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:99acd4dfdfeb58e1937629eb1ab6ab0899b131f183ee5f23e0b5da5cba2fec74", size = 249652, upload-time = "2025-12-08T13:13:13.909Z" },
{ url = "https://files.pythonhosted.org/packages/90/3a/9bfd4de2ff191feb37ef9465855ca56a6f2f30a3bca172e474130731ac3d/coverage-7.13.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ff45e0cd8451e293b63ced93161e189780baf444119391b3e7d25315060368a6", size = 252251, upload-time = "2025-12-08T13:13:15.553Z" },
{ url = "https://files.pythonhosted.org/packages/df/61/b5d8105f016e1b5874af0d7c67542da780ccd4a5f2244a433d3e20ceb1ad/coverage-7.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f4f72a85316d8e13234cafe0a9f81b40418ad7a082792fa4165bd7d45d96066b", size = 253492, upload-time = "2025-12-08T13:13:16.849Z" },
{ url = "https://files.pythonhosted.org/packages/f3/b8/0fad449981803cc47a4694768b99823fb23632150743f9c83af329bb6090/coverage-7.13.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:11c21557d0e0a5a38632cbbaca5f008723b26a89d70db6315523df6df77d6232", size = 249850, upload-time = "2025-12-08T13:13:18.142Z" },
{ url = "https://files.pythonhosted.org/packages/9a/e9/8d68337c3125014d918cf4327d5257553a710a2995a6a6de2ac77e5aa429/coverage-7.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76541dc8d53715fb4f7a3a06b34b0dc6846e3c69bc6204c55653a85dd6220971", size = 251633, upload-time = "2025-12-08T13:13:19.56Z" },
{ url = "https://files.pythonhosted.org/packages/55/14/d4112ab26b3a1bc4b3c1295d8452dcf399ed25be4cf649002fb3e64b2d93/coverage-7.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6e9e451dee940a86789134b6b0ffbe31c454ade3b849bb8a9d2cca2541a8e91d", size = 249586, upload-time = "2025-12-08T13:13:20.883Z" },
{ url = "https://files.pythonhosted.org/packages/2c/a9/22b0000186db663b0d82f86c2f1028099ae9ac202491685051e2a11a5218/coverage-7.13.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5c67dace46f361125e6b9cace8fe0b729ed8479f47e70c89b838d319375c8137", size = 249412, upload-time = "2025-12-08T13:13:22.22Z" },
{ url = "https://files.pythonhosted.org/packages/a1/2e/42d8e0d9e7527fba439acdc6ed24a2b97613b1dc85849b1dd935c2cffef0/coverage-7.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f59883c643cb19630500f57016f76cfdcd6845ca8c5b5ea1f6e17f74c8e5f511", size = 251191, upload-time = "2025-12-08T13:13:23.899Z" },
{ url = "https://files.pythonhosted.org/packages/a4/af/8c7af92b1377fd8860536aadd58745119252aaaa71a5213e5a8e8007a9f5/coverage-7.13.0-cp313-cp313-win32.whl", hash = "sha256:58632b187be6f0be500f553be41e277712baa278147ecb7559983c6d9faf7ae1", size = 220829, upload-time = "2025-12-08T13:13:25.182Z" },
{ url = "https://files.pythonhosted.org/packages/58/f9/725e8bf16f343d33cbe076c75dc8370262e194ff10072c0608b8e5cf33a3/coverage-7.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:73419b89f812f498aca53f757dd834919b48ce4799f9d5cad33ca0ae442bdb1a", size = 221640, upload-time = "2025-12-08T13:13:26.836Z" },
{ url = "https://files.pythonhosted.org/packages/8a/ff/e98311000aa6933cc79274e2b6b94a2fe0fe3434fca778eba82003675496/coverage-7.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:eb76670874fdd6091eedcc856128ee48c41a9bbbb9c3f1c7c3cf169290e3ffd6", size = 220269, upload-time = "2025-12-08T13:13:28.116Z" },
{ url = "https://files.pythonhosted.org/packages/cf/cf/bbaa2e1275b300343ea865f7d424cc0a2e2a1df6925a070b2b2d5d765330/coverage-7.13.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6e63ccc6e0ad8986386461c3c4b737540f20426e7ec932f42e030320896c311a", size = 218990, upload-time = "2025-12-08T13:13:29.463Z" },
{ url = "https://files.pythonhosted.org/packages/21/1d/82f0b3323b3d149d7672e7744c116e9c170f4957e0c42572f0366dbb4477/coverage-7.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:494f5459ffa1bd45e18558cd98710c36c0b8fbfa82a5eabcbe671d80ecffbfe8", size = 219340, upload-time = "2025-12-08T13:13:31.524Z" },
{ url = "https://files.pythonhosted.org/packages/fb/e3/fe3fd4702a3832a255f4d43013eacb0ef5fc155a5960ea9269d8696db28b/coverage-7.13.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:06cac81bf10f74034e055e903f5f946e3e26fc51c09fc9f584e4a1605d977053", size = 260638, upload-time = "2025-12-08T13:13:32.965Z" },
{ url = "https://files.pythonhosted.org/packages/ad/01/63186cb000307f2b4da463f72af9b85d380236965574c78e7e27680a2593/coverage-7.13.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f2ffc92b46ed6e6760f1d47a71e56b5664781bc68986dbd1836b2b70c0ce2071", size = 262705, upload-time = "2025-12-08T13:13:34.378Z" },
{ url = "https://files.pythonhosted.org/packages/7c/a1/c0dacef0cc865f2455d59eed3548573ce47ed603205ffd0735d1d78b5906/coverage-7.13.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0602f701057c6823e5db1b74530ce85f17c3c5be5c85fc042ac939cbd909426e", size = 265125, upload-time = "2025-12-08T13:13:35.73Z" },
{ url = "https://files.pythonhosted.org/packages/ef/92/82b99223628b61300bd382c205795533bed021505eab6dd86e11fb5d7925/coverage-7.13.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:25dc33618d45456ccb1d37bce44bc78cf269909aa14c4db2e03d63146a8a1493", size = 259844, upload-time = "2025-12-08T13:13:37.69Z" },
{ url = "https://files.pythonhosted.org/packages/cf/2c/89b0291ae4e6cd59ef042708e1c438e2290f8c31959a20055d8768349ee2/coverage-7.13.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:71936a8b3b977ddd0b694c28c6a34f4fff2e9dd201969a4ff5d5fc7742d614b0", size = 262700, upload-time = "2025-12-08T13:13:39.525Z" },
{ url = "https://files.pythonhosted.org/packages/bf/f9/a5f992efae1996245e796bae34ceb942b05db275e4b34222a9a40b9fbd3b/coverage-7.13.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:936bc20503ce24770c71938d1369461f0c5320830800933bc3956e2a4ded930e", size = 260321, upload-time = "2025-12-08T13:13:41.172Z" },
{ url = "https://files.pythonhosted.org/packages/4c/89/a29f5d98c64fedbe32e2ac3c227fbf78edc01cc7572eee17d61024d89889/coverage-7.13.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:af0a583efaacc52ae2521f8d7910aff65cdb093091d76291ac5820d5e947fc1c", size = 259222, upload-time = "2025-12-08T13:13:43.282Z" },
{ url = "https://files.pythonhosted.org/packages/b3/c3/940fe447aae302a6701ee51e53af7e08b86ff6eed7631e5740c157ee22b9/coverage-7.13.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f1c23e24a7000da892a312fb17e33c5f94f8b001de44b7cf8ba2e36fbd15859e", size = 261411, upload-time = "2025-12-08T13:13:44.72Z" },
{ url = "https://files.pythonhosted.org/packages/eb/31/12a4aec689cb942a89129587860ed4d0fd522d5fda81237147fde554b8ae/coverage-7.13.0-cp313-cp313t-win32.whl", hash = "sha256:5f8a0297355e652001015e93be345ee54393e45dc3050af4a0475c5a2b767d46", size = 221505, upload-time = "2025-12-08T13:13:46.332Z" },
{ url = "https://files.pythonhosted.org/packages/65/8c/3b5fe3259d863572d2b0827642c50c3855d26b3aefe80bdc9eba1f0af3b0/coverage-7.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6abb3a4c52f05e08460bd9acf04fec027f8718ecaa0d09c40ffbc3fbd70ecc39", size = 222569, upload-time = "2025-12-08T13:13:47.79Z" },
{ url = "https://files.pythonhosted.org/packages/b0/39/f71fa8316a96ac72fc3908839df651e8eccee650001a17f2c78cdb355624/coverage-7.13.0-cp313-cp313t-win_arm64.whl", hash = "sha256:3ad968d1e3aa6ce5be295ab5fe3ae1bf5bb4769d0f98a80a0252d543a2ef2e9e", size = 220841, upload-time = "2025-12-08T13:13:49.243Z" },
{ url = "https://files.pythonhosted.org/packages/f8/4b/9b54bedda55421449811dcd5263a2798a63f48896c24dfb92b0f1b0845bd/coverage-7.13.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:453b7ec753cf5e4356e14fe858064e5520c460d3bbbcb9c35e55c0d21155c256", size = 218343, upload-time = "2025-12-08T13:13:50.811Z" },
{ url = "https://files.pythonhosted.org/packages/59/df/c3a1f34d4bba2e592c8979f924da4d3d4598b0df2392fbddb7761258e3dc/coverage-7.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:af827b7cbb303e1befa6c4f94fd2bf72f108089cfa0f8abab8f4ca553cf5ca5a", size = 218672, upload-time = "2025-12-08T13:13:52.284Z" },
{ url = "https://files.pythonhosted.org/packages/07/62/eec0659e47857698645ff4e6ad02e30186eb8afd65214fd43f02a76537cb/coverage-7.13.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9987a9e4f8197a1000280f7cc089e3ea2c8b3c0a64d750537809879a7b4ceaf9", size = 249715, upload-time = "2025-12-08T13:13:53.791Z" },
{ url = "https://files.pythonhosted.org/packages/23/2d/3c7ff8b2e0e634c1f58d095f071f52ed3c23ff25be524b0ccae8b71f99f8/coverage-7.13.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3188936845cd0cb114fa6a51842a304cdbac2958145d03be2377ec41eb285d19", size = 252225, upload-time = "2025-12-08T13:13:55.274Z" },
{ url = "https://files.pythonhosted.org/packages/aa/ac/fb03b469d20e9c9a81093575003f959cf91a4a517b783aab090e4538764b/coverage-7.13.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2bdb3babb74079f021696cb46b8bb5f5661165c385d3a238712b031a12355be", size = 253559, upload-time = "2025-12-08T13:13:57.161Z" },
{ url = "https://files.pythonhosted.org/packages/29/62/14afa9e792383c66cc0a3b872a06ded6e4ed1079c7d35de274f11d27064e/coverage-7.13.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7464663eaca6adba4175f6c19354feea61ebbdd735563a03d1e472c7072d27bb", size = 249724, upload-time = "2025-12-08T13:13:58.692Z" },
{ url = "https://files.pythonhosted.org/packages/31/b7/333f3dab2939070613696ab3ee91738950f0467778c6e5a5052e840646b7/coverage-7.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8069e831f205d2ff1f3d355e82f511eb7c5522d7d413f5db5756b772ec8697f8", size = 251582, upload-time = "2025-12-08T13:14:00.642Z" },
{ url = "https://files.pythonhosted.org/packages/81/cb/69162bda9381f39b2287265d7e29ee770f7c27c19f470164350a38318764/coverage-7.13.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6fb2d5d272341565f08e962cce14cdf843a08ac43bd621783527adb06b089c4b", size = 249538, upload-time = "2025-12-08T13:14:02.556Z" },
{ url = "https://files.pythonhosted.org/packages/e0/76/350387b56a30f4970abe32b90b2a434f87d29f8b7d4ae40d2e8a85aacfb3/coverage-7.13.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5e70f92ef89bac1ac8a99b3324923b4749f008fdbd7aa9cb35e01d7a284a04f9", size = 249349, upload-time = "2025-12-08T13:14:04.015Z" },
{ url = "https://files.pythonhosted.org/packages/86/0d/7f6c42b8d59f4c7e43ea3059f573c0dcfed98ba46eb43c68c69e52ae095c/coverage-7.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4b5de7d4583e60d5fd246dd57fcd3a8aa23c6e118a8c72b38adf666ba8e7e927", size = 251011, upload-time = "2025-12-08T13:14:05.505Z" },
{ url = "https://files.pythonhosted.org/packages/d7/f1/4bb2dff379721bb0b5c649d5c5eaf438462cad824acf32eb1b7ca0c7078e/coverage-7.13.0-cp314-cp314-win32.whl", hash = "sha256:a6c6e16b663be828a8f0b6c5027d36471d4a9f90d28444aa4ced4d48d7d6ae8f", size = 221091, upload-time = "2025-12-08T13:14:07.127Z" },
{ url = "https://files.pythonhosted.org/packages/ba/44/c239da52f373ce379c194b0ee3bcc121020e397242b85f99e0afc8615066/coverage-7.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:0900872f2fdb3ee5646b557918d02279dc3af3dfb39029ac4e945458b13f73bc", size = 221904, upload-time = "2025-12-08T13:14:08.542Z" },
{ url = "https://files.pythonhosted.org/packages/89/1f/b9f04016d2a29c2e4a0307baefefad1a4ec5724946a2b3e482690486cade/coverage-7.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:3a10260e6a152e5f03f26db4a407c4c62d3830b9af9b7c0450b183615f05d43b", size = 220480, upload-time = "2025-12-08T13:14:10.958Z" },
{ url = "https://files.pythonhosted.org/packages/16/d4/364a1439766c8e8647860584171c36010ca3226e6e45b1753b1b249c5161/coverage-7.13.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9097818b6cc1cfb5f174e3263eba4a62a17683bcfe5c4b5d07f4c97fa51fbf28", size = 219074, upload-time = "2025-12-08T13:14:13.345Z" },
{ url = "https://files.pythonhosted.org/packages/ce/f4/71ba8be63351e099911051b2089662c03d5671437a0ec2171823c8e03bec/coverage-7.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0018f73dfb4301a89292c73be6ba5f58722ff79f51593352759c1790ded1cabe", size = 219342, upload-time = "2025-12-08T13:14:15.02Z" },
{ url = "https://files.pythonhosted.org/packages/5e/25/127d8ed03d7711a387d96f132589057213e3aef7475afdaa303412463f22/coverage-7.13.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:166ad2a22ee770f5656e1257703139d3533b4a0b6909af67c6b4a3adc1c98657", size = 260713, upload-time = "2025-12-08T13:14:16.907Z" },
{ url = "https://files.pythonhosted.org/packages/fd/db/559fbb6def07d25b2243663b46ba9eb5a3c6586c0c6f4e62980a68f0ee1c/coverage-7.13.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f6aaef16d65d1787280943f1c8718dc32e9cf141014e4634d64446702d26e0ff", size = 262825, upload-time = "2025-12-08T13:14:18.68Z" },
{ url = "https://files.pythonhosted.org/packages/37/99/6ee5bf7eff884766edb43bd8736b5e1c5144d0fe47498c3779326fe75a35/coverage-7.13.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e999e2dcc094002d6e2c7bbc1fb85b58ba4f465a760a8014d97619330cdbbbf3", size = 265233, upload-time = "2025-12-08T13:14:20.55Z" },
{ url = "https://files.pythonhosted.org/packages/d8/90/92f18fe0356ea69e1f98f688ed80cec39f44e9f09a1f26a1bbf017cc67f2/coverage-7.13.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:00c3d22cf6fb1cf3bf662aaaa4e563be8243a5ed2630339069799835a9cc7f9b", size = 259779, upload-time = "2025-12-08T13:14:22.367Z" },
{ url = "https://files.pythonhosted.org/packages/90/5d/b312a8b45b37a42ea7d27d7d3ff98ade3a6c892dd48d1d503e773503373f/coverage-7.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22ccfe8d9bb0d6134892cbe1262493a8c70d736b9df930f3f3afae0fe3ac924d", size = 262700, upload-time = "2025-12-08T13:14:24.309Z" },
{ url = "https://files.pythonhosted.org/packages/63/f8/b1d0de5c39351eb71c366f872376d09386640840a2e09b0d03973d791e20/coverage-7.13.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9372dff5ea15930fea0445eaf37bbbafbc771a49e70c0aeed8b4e2c2614cc00e", size = 260302, upload-time = "2025-12-08T13:14:26.068Z" },
{ url = "https://files.pythonhosted.org/packages/aa/7c/d42f4435bc40c55558b3109a39e2d456cddcec37434f62a1f1230991667a/coverage-7.13.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:69ac2c492918c2461bc6ace42d0479638e60719f2a4ef3f0815fa2df88e9f940", size = 259136, upload-time = "2025-12-08T13:14:27.604Z" },
{ url = "https://files.pythonhosted.org/packages/b8/d3/23413241dc04d47cfe19b9a65b32a2edd67ecd0b817400c2843ebc58c847/coverage-7.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:739c6c051a7540608d097b8e13c76cfa85263ced467168dc6b477bae3df7d0e2", size = 261467, upload-time = "2025-12-08T13:14:29.09Z" },
{ url = "https://files.pythonhosted.org/packages/13/e6/6e063174500eee216b96272c0d1847bf215926786f85c2bd024cf4d02d2f/coverage-7.13.0-cp314-cp314t-win32.whl", hash = "sha256:fe81055d8c6c9de76d60c94ddea73c290b416e061d40d542b24a5871bad498b7", size = 221875, upload-time = "2025-12-08T13:14:31.106Z" },
{ url = "https://files.pythonhosted.org/packages/3b/46/f4fb293e4cbe3620e3ac2a3e8fd566ed33affb5861a9b20e3dd6c1896cbc/coverage-7.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:445badb539005283825959ac9fa4a28f712c214b65af3a2c464f1adc90f5fcbc", size = 222982, upload-time = "2025-12-08T13:14:33.1Z" },
{ url = "https://files.pythonhosted.org/packages/68/62/5b3b9018215ed9733fbd1ae3b2ed75c5de62c3b55377a52cae732e1b7805/coverage-7.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:de7f6748b890708578fc4b7bb967d810aeb6fcc9bff4bb77dbca77dab2f9df6a", size = 221016, upload-time = "2025-12-08T13:14:34.601Z" },
{ url = "https://files.pythonhosted.org/packages/8d/4c/1968f32fb9a2604645827e11ff84a31e59d532e01995f904723b4f5328b3/coverage-7.13.0-py3-none-any.whl", hash = "sha256:850d2998f380b1e266459ca5b47bc9e7daf9af1d070f66317972f382d46f1904", size = 210068, upload-time = "2025-12-08T13:14:36.236Z" },
{ url = "https://files.pythonhosted.org/packages/5a/0c/0dfe7f0487477d96432e4815537263363fb6dd7289743a796e8e51eabdf2/coverage-7.12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:aa124a3683d2af98bd9d9c2bfa7a5076ca7e5ab09fdb96b81fa7d89376ae928f", size = 217535, upload-time = "2025-11-18T13:32:08.812Z" },
{ url = "https://files.pythonhosted.org/packages/9b/f5/f9a4a053a5bbff023d3bec259faac8f11a1e5a6479c2ccf586f910d8dac7/coverage-7.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d93fbf446c31c0140208dcd07c5d882029832e8ed7891a39d6d44bd65f2316c3", size = 218044, upload-time = "2025-11-18T13:32:10.329Z" },
{ url = "https://files.pythonhosted.org/packages/95/c5/84fc3697c1fa10cd8571919bf9693f693b7373278daaf3b73e328d502bc8/coverage-7.12.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:52ca620260bd8cd6027317bdd8b8ba929be1d741764ee765b42c4d79a408601e", size = 248440, upload-time = "2025-11-18T13:32:12.536Z" },
{ url = "https://files.pythonhosted.org/packages/f4/36/2d93fbf6a04670f3874aed397d5a5371948a076e3249244a9e84fb0e02d6/coverage-7.12.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f3433ffd541380f3a0e423cff0f4926d55b0cc8c1d160fdc3be24a4c03aa65f7", size = 250361, upload-time = "2025-11-18T13:32:13.852Z" },
{ url = "https://files.pythonhosted.org/packages/5d/49/66dc65cc456a6bfc41ea3d0758c4afeaa4068a2b2931bf83be6894cf1058/coverage-7.12.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7bbb321d4adc9f65e402c677cd1c8e4c2d0105d3ce285b51b4d87f1d5db5245", size = 252472, upload-time = "2025-11-18T13:32:15.068Z" },
{ url = "https://files.pythonhosted.org/packages/35/1f/ebb8a18dffd406db9fcd4b3ae42254aedcaf612470e8712f12041325930f/coverage-7.12.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22a7aade354a72dff3b59c577bfd18d6945c61f97393bc5fb7bd293a4237024b", size = 248592, upload-time = "2025-11-18T13:32:16.328Z" },
{ url = "https://files.pythonhosted.org/packages/da/a8/67f213c06e5ea3b3d4980df7dc344d7fea88240b5fe878a5dcbdfe0e2315/coverage-7.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3ff651dcd36d2fea66877cd4a82de478004c59b849945446acb5baf9379a1b64", size = 250167, upload-time = "2025-11-18T13:32:17.687Z" },
{ url = "https://files.pythonhosted.org/packages/f0/00/e52aef68154164ea40cc8389c120c314c747fe63a04b013a5782e989b77f/coverage-7.12.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:31b8b2e38391a56e3cea39d22a23faaa7c3fc911751756ef6d2621d2a9daf742", size = 248238, upload-time = "2025-11-18T13:32:19.2Z" },
{ url = "https://files.pythonhosted.org/packages/1f/a4/4d88750bcf9d6d66f77865e5a05a20e14db44074c25fd22519777cb69025/coverage-7.12.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:297bc2da28440f5ae51c845a47c8175a4db0553a53827886e4fb25c66633000c", size = 247964, upload-time = "2025-11-18T13:32:21.027Z" },
{ url = "https://files.pythonhosted.org/packages/a7/6b/b74693158899d5b47b0bf6238d2c6722e20ba749f86b74454fac0696bb00/coverage-7.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6ff7651cc01a246908eac162a6a86fc0dbab6de1ad165dfb9a1e2ec660b44984", size = 248862, upload-time = "2025-11-18T13:32:22.304Z" },
{ url = "https://files.pythonhosted.org/packages/18/de/6af6730227ce0e8ade307b1cc4a08e7f51b419a78d02083a86c04ccceb29/coverage-7.12.0-cp311-cp311-win32.whl", hash = "sha256:313672140638b6ddb2c6455ddeda41c6a0b208298034544cfca138978c6baed6", size = 220033, upload-time = "2025-11-18T13:32:23.714Z" },
{ url = "https://files.pythonhosted.org/packages/e2/a1/e7f63021a7c4fe20994359fcdeae43cbef4a4d0ca36a5a1639feeea5d9e1/coverage-7.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:a1783ed5bd0d5938d4435014626568dc7f93e3cb99bc59188cc18857c47aa3c4", size = 220966, upload-time = "2025-11-18T13:32:25.599Z" },
{ url = "https://files.pythonhosted.org/packages/77/e8/deae26453f37c20c3aa0c4433a1e32cdc169bf415cce223a693117aa3ddd/coverage-7.12.0-cp311-cp311-win_arm64.whl", hash = "sha256:4648158fd8dd9381b5847622df1c90ff314efbfc1df4550092ab6013c238a5fc", size = 219637, upload-time = "2025-11-18T13:32:27.265Z" },
{ url = "https://files.pythonhosted.org/packages/02/bf/638c0427c0f0d47638242e2438127f3c8ee3cfc06c7fdeb16778ed47f836/coverage-7.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:29644c928772c78512b48e14156b81255000dcfd4817574ff69def189bcb3647", size = 217704, upload-time = "2025-11-18T13:32:28.906Z" },
{ url = "https://files.pythonhosted.org/packages/08/e1/706fae6692a66c2d6b871a608bbde0da6281903fa0e9f53a39ed441da36a/coverage-7.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8638cbb002eaa5d7c8d04da667813ce1067080b9a91099801a0053086e52b736", size = 218064, upload-time = "2025-11-18T13:32:30.161Z" },
{ url = "https://files.pythonhosted.org/packages/a9/8b/eb0231d0540f8af3ffda39720ff43cb91926489d01524e68f60e961366e4/coverage-7.12.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:083631eeff5eb9992c923e14b810a179798bb598e6a0dd60586819fc23be6e60", size = 249560, upload-time = "2025-11-18T13:32:31.835Z" },
{ url = "https://files.pythonhosted.org/packages/e9/a1/67fb52af642e974d159b5b379e4d4c59d0ebe1288677fbd04bbffe665a82/coverage-7.12.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:99d5415c73ca12d558e07776bd957c4222c687b9f1d26fa0e1b57e3598bdcde8", size = 252318, upload-time = "2025-11-18T13:32:33.178Z" },
{ url = "https://files.pythonhosted.org/packages/41/e5/38228f31b2c7665ebf9bdfdddd7a184d56450755c7e43ac721c11a4b8dab/coverage-7.12.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e949ebf60c717c3df63adb4a1a366c096c8d7fd8472608cd09359e1bd48ef59f", size = 253403, upload-time = "2025-11-18T13:32:34.45Z" },
{ url = "https://files.pythonhosted.org/packages/ec/4b/df78e4c8188f9960684267c5a4897836f3f0f20a20c51606ee778a1d9749/coverage-7.12.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6d907ddccbca819afa2cd014bc69983b146cca2735a0b1e6259b2a6c10be1e70", size = 249984, upload-time = "2025-11-18T13:32:35.747Z" },
{ url = "https://files.pythonhosted.org/packages/ba/51/bb163933d195a345c6f63eab9e55743413d064c291b6220df754075c2769/coverage-7.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b1518ecbad4e6173f4c6e6c4a46e49555ea5679bf3feda5edb1b935c7c44e8a0", size = 251339, upload-time = "2025-11-18T13:32:37.352Z" },
{ url = "https://files.pythonhosted.org/packages/15/40/c9b29cdb8412c837cdcbc2cfa054547dd83affe6cbbd4ce4fdb92b6ba7d1/coverage-7.12.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:51777647a749abdf6f6fd8c7cffab12de68ab93aab15efc72fbbb83036c2a068", size = 249489, upload-time = "2025-11-18T13:32:39.212Z" },
{ url = "https://files.pythonhosted.org/packages/c8/da/b3131e20ba07a0de4437a50ef3b47840dfabf9293675b0cd5c2c7f66dd61/coverage-7.12.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:42435d46d6461a3b305cdfcad7cdd3248787771f53fe18305548cba474e6523b", size = 249070, upload-time = "2025-11-18T13:32:40.598Z" },
{ url = "https://files.pythonhosted.org/packages/70/81/b653329b5f6302c08d683ceff6785bc60a34be9ae92a5c7b63ee7ee7acec/coverage-7.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5bcead88c8423e1855e64b8057d0544e33e4080b95b240c2a355334bb7ced937", size = 250929, upload-time = "2025-11-18T13:32:42.915Z" },
{ url = "https://files.pythonhosted.org/packages/a3/00/250ac3bca9f252a5fb1338b5ad01331ebb7b40223f72bef5b1b2cb03aa64/coverage-7.12.0-cp312-cp312-win32.whl", hash = "sha256:dcbb630ab034e86d2a0f79aefd2be07e583202f41e037602d438c80044957baa", size = 220241, upload-time = "2025-11-18T13:32:44.665Z" },
{ url = "https://files.pythonhosted.org/packages/64/1c/77e79e76d37ce83302f6c21980b45e09f8aa4551965213a10e62d71ce0ab/coverage-7.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:2fd8354ed5d69775ac42986a691fbf68b4084278710cee9d7c3eaa0c28fa982a", size = 221051, upload-time = "2025-11-18T13:32:46.008Z" },
{ url = "https://files.pythonhosted.org/packages/31/f5/641b8a25baae564f9e52cac0e2667b123de961985709a004e287ee7663cc/coverage-7.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:737c3814903be30695b2de20d22bcc5428fdae305c61ba44cdc8b3252984c49c", size = 219692, upload-time = "2025-11-18T13:32:47.372Z" },
{ url = "https://files.pythonhosted.org/packages/b8/14/771700b4048774e48d2c54ed0c674273702713c9ee7acdfede40c2666747/coverage-7.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:47324fffca8d8eae7e185b5bb20c14645f23350f870c1649003618ea91a78941", size = 217725, upload-time = "2025-11-18T13:32:49.22Z" },
{ url = "https://files.pythonhosted.org/packages/17/a7/3aa4144d3bcb719bf67b22d2d51c2d577bf801498c13cb08f64173e80497/coverage-7.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ccf3b2ede91decd2fb53ec73c1f949c3e034129d1e0b07798ff1d02ea0c8fa4a", size = 218098, upload-time = "2025-11-18T13:32:50.78Z" },
{ url = "https://files.pythonhosted.org/packages/fc/9c/b846bbc774ff81091a12a10203e70562c91ae71badda00c5ae5b613527b1/coverage-7.12.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b365adc70a6936c6b0582dc38746b33b2454148c02349345412c6e743efb646d", size = 249093, upload-time = "2025-11-18T13:32:52.554Z" },
{ url = "https://files.pythonhosted.org/packages/76/b6/67d7c0e1f400b32c883e9342de4a8c2ae7c1a0b57c5de87622b7262e2309/coverage-7.12.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bc13baf85cd8a4cfcf4a35c7bc9d795837ad809775f782f697bf630b7e200211", size = 251686, upload-time = "2025-11-18T13:32:54.862Z" },
{ url = "https://files.pythonhosted.org/packages/cc/75/b095bd4b39d49c3be4bffbb3135fea18a99a431c52dd7513637c0762fecb/coverage-7.12.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:099d11698385d572ceafb3288a5b80fe1fc58bf665b3f9d362389de488361d3d", size = 252930, upload-time = "2025-11-18T13:32:56.417Z" },
{ url = "https://files.pythonhosted.org/packages/6e/f3/466f63015c7c80550bead3093aacabf5380c1220a2a93c35d374cae8f762/coverage-7.12.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:473dc45d69694069adb7680c405fb1e81f60b2aff42c81e2f2c3feaf544d878c", size = 249296, upload-time = "2025-11-18T13:32:58.074Z" },
{ url = "https://files.pythonhosted.org/packages/27/86/eba2209bf2b7e28c68698fc13437519a295b2d228ba9e0ec91673e09fa92/coverage-7.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:583f9adbefd278e9de33c33d6846aa8f5d164fa49b47144180a0e037f0688bb9", size = 251068, upload-time = "2025-11-18T13:32:59.646Z" },
{ url = "https://files.pythonhosted.org/packages/ec/55/ca8ae7dbba962a3351f18940b359b94c6bafdd7757945fdc79ec9e452dc7/coverage-7.12.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b2089cc445f2dc0af6f801f0d1355c025b76c24481935303cf1af28f636688f0", size = 249034, upload-time = "2025-11-18T13:33:01.481Z" },
{ url = "https://files.pythonhosted.org/packages/7a/d7/39136149325cad92d420b023b5fd900dabdd1c3a0d1d5f148ef4a8cedef5/coverage-7.12.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:950411f1eb5d579999c5f66c62a40961f126fc71e5e14419f004471957b51508", size = 248853, upload-time = "2025-11-18T13:33:02.935Z" },
{ url = "https://files.pythonhosted.org/packages/fe/b6/76e1add8b87ef60e00643b0b7f8f7bb73d4bf5249a3be19ebefc5793dd25/coverage-7.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b1aab7302a87bafebfe76b12af681b56ff446dc6f32ed178ff9c092ca776e6bc", size = 250619, upload-time = "2025-11-18T13:33:04.336Z" },
{ url = "https://files.pythonhosted.org/packages/95/87/924c6dc64f9203f7a3c1832a6a0eee5a8335dbe5f1bdadcc278d6f1b4d74/coverage-7.12.0-cp313-cp313-win32.whl", hash = "sha256:d7e0d0303c13b54db495eb636bc2465b2fb8475d4c8bcec8fe4b5ca454dfbae8", size = 220261, upload-time = "2025-11-18T13:33:06.493Z" },
{ url = "https://files.pythonhosted.org/packages/91/77/dd4aff9af16ff776bf355a24d87eeb48fc6acde54c907cc1ea89b14a8804/coverage-7.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:ce61969812d6a98a981d147d9ac583a36ac7db7766f2e64a9d4d059c2fe29d07", size = 221072, upload-time = "2025-11-18T13:33:07.926Z" },
{ url = "https://files.pythonhosted.org/packages/70/49/5c9dc46205fef31b1b226a6e16513193715290584317fd4df91cdaf28b22/coverage-7.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:bcec6f47e4cb8a4c2dc91ce507f6eefc6a1b10f58df32cdc61dff65455031dfc", size = 219702, upload-time = "2025-11-18T13:33:09.631Z" },
{ url = "https://files.pythonhosted.org/packages/9b/62/f87922641c7198667994dd472a91e1d9b829c95d6c29529ceb52132436ad/coverage-7.12.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:459443346509476170d553035e4a3eed7b860f4fe5242f02de1010501956ce87", size = 218420, upload-time = "2025-11-18T13:33:11.153Z" },
{ url = "https://files.pythonhosted.org/packages/85/dd/1cc13b2395ef15dbb27d7370a2509b4aee77890a464fb35d72d428f84871/coverage-7.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:04a79245ab2b7a61688958f7a855275997134bc84f4a03bc240cf64ff132abf6", size = 218773, upload-time = "2025-11-18T13:33:12.569Z" },
{ url = "https://files.pythonhosted.org/packages/74/40/35773cc4bb1e9d4658d4fb669eb4195b3151bef3bbd6f866aba5cd5dac82/coverage-7.12.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:09a86acaaa8455f13d6a99221d9654df249b33937b4e212b4e5a822065f12aa7", size = 260078, upload-time = "2025-11-18T13:33:14.037Z" },
{ url = "https://files.pythonhosted.org/packages/ec/ee/231bb1a6ffc2905e396557585ebc6bdc559e7c66708376d245a1f1d330fc/coverage-7.12.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:907e0df1b71ba77463687a74149c6122c3f6aac56c2510a5d906b2f368208560", size = 262144, upload-time = "2025-11-18T13:33:15.601Z" },
{ url = "https://files.pythonhosted.org/packages/28/be/32f4aa9f3bf0b56f3971001b56508352c7753915345d45fab4296a986f01/coverage-7.12.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b57e2d0ddd5f0582bae5437c04ee71c46cd908e7bc5d4d0391f9a41e812dd12", size = 264574, upload-time = "2025-11-18T13:33:17.354Z" },
{ url = "https://files.pythonhosted.org/packages/68/7c/00489fcbc2245d13ab12189b977e0cf06ff3351cb98bc6beba8bd68c5902/coverage-7.12.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:58c1c6aa677f3a1411fe6fb28ec3a942e4f665df036a3608816e0847fad23296", size = 259298, upload-time = "2025-11-18T13:33:18.958Z" },
{ url = "https://files.pythonhosted.org/packages/96/b4/f0760d65d56c3bea95b449e02570d4abd2549dc784bf39a2d4721a2d8ceb/coverage-7.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4c589361263ab2953e3c4cd2a94db94c4ad4a8e572776ecfbad2389c626e4507", size = 262150, upload-time = "2025-11-18T13:33:20.644Z" },
{ url = "https://files.pythonhosted.org/packages/c5/71/9a9314df00f9326d78c1e5a910f520d599205907432d90d1c1b7a97aa4b1/coverage-7.12.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:91b810a163ccad2e43b1faa11d70d3cf4b6f3d83f9fd5f2df82a32d47b648e0d", size = 259763, upload-time = "2025-11-18T13:33:22.189Z" },
{ url = "https://files.pythonhosted.org/packages/10/34/01a0aceed13fbdf925876b9a15d50862eb8845454301fe3cdd1df08b2182/coverage-7.12.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:40c867af715f22592e0d0fb533a33a71ec9e0f73a6945f722a0c85c8c1cbe3a2", size = 258653, upload-time = "2025-11-18T13:33:24.239Z" },
{ url = "https://files.pythonhosted.org/packages/8d/04/81d8fd64928acf1574bbb0181f66901c6c1c6279c8ccf5f84259d2c68ae9/coverage-7.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:68b0d0a2d84f333de875666259dadf28cc67858bc8fd8b3f1eae84d3c2bec455", size = 260856, upload-time = "2025-11-18T13:33:26.365Z" },
{ url = "https://files.pythonhosted.org/packages/f2/76/fa2a37bfaeaf1f766a2d2360a25a5297d4fb567098112f6517475eee120b/coverage-7.12.0-cp313-cp313t-win32.whl", hash = "sha256:73f9e7fbd51a221818fd11b7090eaa835a353ddd59c236c57b2199486b116c6d", size = 220936, upload-time = "2025-11-18T13:33:28.165Z" },
{ url = "https://files.pythonhosted.org/packages/f9/52/60f64d932d555102611c366afb0eb434b34266b1d9266fc2fe18ab641c47/coverage-7.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:24cff9d1f5743f67db7ba46ff284018a6e9aeb649b67aa1e70c396aa1b7cb23c", size = 222001, upload-time = "2025-11-18T13:33:29.656Z" },
{ url = "https://files.pythonhosted.org/packages/77/df/c303164154a5a3aea7472bf323b7c857fed93b26618ed9fc5c2955566bb0/coverage-7.12.0-cp313-cp313t-win_arm64.whl", hash = "sha256:c87395744f5c77c866d0f5a43d97cc39e17c7f1cb0115e54a2fe67ca75c5d14d", size = 220273, upload-time = "2025-11-18T13:33:31.415Z" },
{ url = "https://files.pythonhosted.org/packages/bf/2e/fc12db0883478d6e12bbd62d481210f0c8daf036102aa11434a0c5755825/coverage-7.12.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a1c59b7dc169809a88b21a936eccf71c3895a78f5592051b1af8f4d59c2b4f92", size = 217777, upload-time = "2025-11-18T13:33:32.86Z" },
{ url = "https://files.pythonhosted.org/packages/1f/c1/ce3e525d223350c6ec16b9be8a057623f54226ef7f4c2fee361ebb6a02b8/coverage-7.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8787b0f982e020adb732b9f051f3e49dd5054cebbc3f3432061278512a2b1360", size = 218100, upload-time = "2025-11-18T13:33:34.532Z" },
{ url = "https://files.pythonhosted.org/packages/15/87/113757441504aee3808cb422990ed7c8bcc2d53a6779c66c5adef0942939/coverage-7.12.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5ea5a9f7dc8877455b13dd1effd3202e0bca72f6f3ab09f9036b1bcf728f69ac", size = 249151, upload-time = "2025-11-18T13:33:36.135Z" },
{ url = "https://files.pythonhosted.org/packages/d9/1d/9529d9bd44049b6b05bb319c03a3a7e4b0a8a802d28fa348ad407e10706d/coverage-7.12.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fdba9f15849534594f60b47c9a30bc70409b54947319a7c4fd0e8e3d8d2f355d", size = 251667, upload-time = "2025-11-18T13:33:37.996Z" },
{ url = "https://files.pythonhosted.org/packages/11/bb/567e751c41e9c03dc29d3ce74b8c89a1e3396313e34f255a2a2e8b9ebb56/coverage-7.12.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a00594770eb715854fb1c57e0dea08cce6720cfbc531accdb9850d7c7770396c", size = 253003, upload-time = "2025-11-18T13:33:39.553Z" },
{ url = "https://files.pythonhosted.org/packages/e4/b3/c2cce2d8526a02fb9e9ca14a263ca6fc074449b33a6afa4892838c903528/coverage-7.12.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5560c7e0d82b42eb1951e4f68f071f8017c824ebfd5a6ebe42c60ac16c6c2434", size = 249185, upload-time = "2025-11-18T13:33:42.086Z" },
{ url = "https://files.pythonhosted.org/packages/0e/a7/967f93bb66e82c9113c66a8d0b65ecf72fc865adfba5a145f50c7af7e58d/coverage-7.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d6c2e26b481c9159c2773a37947a9718cfdc58893029cdfb177531793e375cfc", size = 251025, upload-time = "2025-11-18T13:33:43.634Z" },
{ url = "https://files.pythonhosted.org/packages/b9/b2/f2f6f56337bc1af465d5b2dc1ee7ee2141b8b9272f3bf6213fcbc309a836/coverage-7.12.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6e1a8c066dabcde56d5d9fed6a66bc19a2883a3fe051f0c397a41fc42aedd4cc", size = 248979, upload-time = "2025-11-18T13:33:46.04Z" },
{ url = "https://files.pythonhosted.org/packages/f4/7a/bf4209f45a4aec09d10a01a57313a46c0e0e8f4c55ff2965467d41a92036/coverage-7.12.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f7ba9da4726e446d8dd8aae5a6cd872511184a5d861de80a86ef970b5dacce3e", size = 248800, upload-time = "2025-11-18T13:33:47.546Z" },
{ url = "https://files.pythonhosted.org/packages/b8/b7/1e01b8696fb0521810f60c5bbebf699100d6754183e6cc0679bf2ed76531/coverage-7.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e0f483ab4f749039894abaf80c2f9e7ed77bbf3c737517fb88c8e8e305896a17", size = 250460, upload-time = "2025-11-18T13:33:49.537Z" },
{ url = "https://files.pythonhosted.org/packages/71/ae/84324fb9cb46c024760e706353d9b771a81b398d117d8c1fe010391c186f/coverage-7.12.0-cp314-cp314-win32.whl", hash = "sha256:76336c19a9ef4a94b2f8dc79f8ac2da3f193f625bb5d6f51a328cd19bfc19933", size = 220533, upload-time = "2025-11-18T13:33:51.16Z" },
{ url = "https://files.pythonhosted.org/packages/e2/71/1033629deb8460a8f97f83e6ac4ca3b93952e2b6f826056684df8275e015/coverage-7.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:7c1059b600aec6ef090721f8f633f60ed70afaffe8ecab85b59df748f24b31fe", size = 221348, upload-time = "2025-11-18T13:33:52.776Z" },
{ url = "https://files.pythonhosted.org/packages/0a/5f/ac8107a902f623b0c251abdb749be282dc2ab61854a8a4fcf49e276fce2f/coverage-7.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:172cf3a34bfef42611963e2b661302a8931f44df31629e5b1050567d6b90287d", size = 219922, upload-time = "2025-11-18T13:33:54.316Z" },
{ url = "https://files.pythonhosted.org/packages/79/6e/f27af2d4da367f16077d21ef6fe796c874408219fa6dd3f3efe7751bd910/coverage-7.12.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:aa7d48520a32cb21c7a9b31f81799e8eaec7239db36c3b670be0fa2403828d1d", size = 218511, upload-time = "2025-11-18T13:33:56.343Z" },
{ url = "https://files.pythonhosted.org/packages/67/dd/65fd874aa460c30da78f9d259400d8e6a4ef457d61ab052fd248f0050558/coverage-7.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:90d58ac63bc85e0fb919f14d09d6caa63f35a5512a2205284b7816cafd21bb03", size = 218771, upload-time = "2025-11-18T13:33:57.966Z" },
{ url = "https://files.pythonhosted.org/packages/55/e0/7c6b71d327d8068cb79c05f8f45bf1b6145f7a0de23bbebe63578fe5240a/coverage-7.12.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ca8ecfa283764fdda3eae1bdb6afe58bf78c2c3ec2b2edcb05a671f0bba7b3f9", size = 260151, upload-time = "2025-11-18T13:33:59.597Z" },
{ url = "https://files.pythonhosted.org/packages/49/ce/4697457d58285b7200de6b46d606ea71066c6e674571a946a6ea908fb588/coverage-7.12.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:874fe69a0785d96bd066059cd4368022cebbec1a8958f224f0016979183916e6", size = 262257, upload-time = "2025-11-18T13:34:01.166Z" },
{ url = "https://files.pythonhosted.org/packages/2f/33/acbc6e447aee4ceba88c15528dbe04a35fb4d67b59d393d2e0d6f1e242c1/coverage-7.12.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5b3c889c0b8b283a24d721a9eabc8ccafcfc3aebf167e4cd0d0e23bf8ec4e339", size = 264671, upload-time = "2025-11-18T13:34:02.795Z" },
{ url = "https://files.pythonhosted.org/packages/87/ec/e2822a795c1ed44d569980097be839c5e734d4c0c1119ef8e0a073496a30/coverage-7.12.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8bb5b894b3ec09dcd6d3743229dc7f2c42ef7787dc40596ae04c0edda487371e", size = 259231, upload-time = "2025-11-18T13:34:04.397Z" },
{ url = "https://files.pythonhosted.org/packages/72/c5/a7ec5395bb4a49c9b7ad97e63f0c92f6bf4a9e006b1393555a02dae75f16/coverage-7.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:79a44421cd5fba96aa57b5e3b5a4d3274c449d4c622e8f76882d76635501fd13", size = 262137, upload-time = "2025-11-18T13:34:06.068Z" },
{ url = "https://files.pythonhosted.org/packages/67/0c/02c08858b764129f4ecb8e316684272972e60777ae986f3865b10940bdd6/coverage-7.12.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:33baadc0efd5c7294f436a632566ccc1f72c867f82833eb59820ee37dc811c6f", size = 259745, upload-time = "2025-11-18T13:34:08.04Z" },
{ url = "https://files.pythonhosted.org/packages/5a/04/4fd32b7084505f3829a8fe45c1a74a7a728cb251aaadbe3bec04abcef06d/coverage-7.12.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c406a71f544800ef7e9e0000af706b88465f3573ae8b8de37e5f96c59f689ad1", size = 258570, upload-time = "2025-11-18T13:34:09.676Z" },
{ url = "https://files.pythonhosted.org/packages/48/35/2365e37c90df4f5342c4fa202223744119fe31264ee2924f09f074ea9b6d/coverage-7.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e71bba6a40883b00c6d571599b4627f50c360b3d0d02bfc658168936be74027b", size = 260899, upload-time = "2025-11-18T13:34:11.259Z" },
{ url = "https://files.pythonhosted.org/packages/05/56/26ab0464ca733fa325e8e71455c58c1c374ce30f7c04cebb88eabb037b18/coverage-7.12.0-cp314-cp314t-win32.whl", hash = "sha256:9157a5e233c40ce6613dead4c131a006adfda70e557b6856b97aceed01b0e27a", size = 221313, upload-time = "2025-11-18T13:34:12.863Z" },
{ url = "https://files.pythonhosted.org/packages/da/1c/017a3e1113ed34d998b27d2c6dba08a9e7cb97d362f0ec988fcd873dcf81/coverage-7.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e84da3a0fd233aeec797b981c51af1cabac74f9bd67be42458365b30d11b5291", size = 222423, upload-time = "2025-11-18T13:34:15.14Z" },
{ url = "https://files.pythonhosted.org/packages/4c/36/bcc504fdd5169301b52568802bb1b9cdde2e27a01d39fbb3b4b508ab7c2c/coverage-7.12.0-cp314-cp314t-win_arm64.whl", hash = "sha256:01d24af36fedda51c2b1aca56e4330a3710f83b02a5ff3743a6b015ffa7c9384", size = 220459, upload-time = "2025-11-18T13:34:17.222Z" },
{ url = "https://files.pythonhosted.org/packages/ce/a3/43b749004e3c09452e39bb56347a008f0a0668aad37324a99b5c8ca91d9e/coverage-7.12.0-py3-none-any.whl", hash = "sha256:159d50c0b12e060b15ed3d39f87ed43d4f7f7ad40b8a534f4dd331adbb51104a", size = 209503, upload-time = "2025-11-18T13:34:18.892Z" },
]
[package.optional-dependencies]
@@ -247,7 +247,6 @@ wheels = [
[[package]]
name = "dibbler"
version = "1.0.0"
source = { editable = "." }
dependencies = [
{ name = "brother-ql" },
@@ -261,7 +260,6 @@ dependencies = [
test = [
{ name = "coverage-badge" },
{ name = "pytest" },
{ name = "pytest-benchmark", extra = ["histogram"] },
{ name = "pytest-cov" },
{ name = "pytest-html" },
{ name = "sqlparse" },
@@ -280,7 +278,6 @@ requires-dist = [
test = [
{ name = "coverage-badge", specifier = ">=1.1.2" },
{ name = "pytest" },
{ name = "pytest-benchmark", extras = ["histogram"], specifier = ">=5.2.3" },
{ name = "pytest-cov" },
{ name = "pytest-html", specifier = ">=4.1.1" },
{ name = "sqlparse", specifier = ">=0.5.4" },
@@ -391,18 +388,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/4f/dc/041be1dff9f23dac5f48a43323cd0789cb798342011c19a248d9c9335536/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c10513330af5b8ae16f023e8ddbfb486ab355d04467c4679c5cfe4659975dd9", size = 1676034, upload-time = "2025-12-04T14:27:33.531Z" },
]
[[package]]
name = "importlib-metadata"
version = "8.7.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "zipp" },
]
sdist = { url = "https://files.pythonhosted.org/packages/76/66/650a33bd90f786193e4de4b3ad86ea60b53c89b669a5c7be931fac31cdb0/importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000", size = 56641, upload-time = "2025-04-27T15:29:01.736Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/20/b0/36bd937216ec521246249be3bf9855081de4c5e06a0c9b4219dbeda50373/importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd", size = 27656, upload-time = "2025-04-27T15:29:00.214Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
@@ -896,36 +881,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e1/36/9c0c326fe3a4227953dfb29f5d0c8ae3b8eb8c1cd2967aa569f50cb3c61f/psycopg2_binary-2.9.11-cp314-cp314-win_amd64.whl", hash = "sha256:4012c9c954dfaccd28f94e84ab9f94e12df76b4afb22331b1f0d3154893a6316", size = 2803913, upload-time = "2025-10-10T11:13:57.058Z" },
]
[[package]]
name = "py-cpuinfo"
version = "9.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/37/a8/d832f7293ebb21690860d2e01d8115e5ff6f2ae8bbdc953f0eb0fa4bd2c7/py-cpuinfo-9.0.0.tar.gz", hash = "sha256:3cdbbf3fac90dc6f118bfd64384f309edeadd902d7c8fb17f02ffa1fc3f49690", size = 104716, upload-time = "2022-10-25T20:38:06.303Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/a9/023730ba63db1e494a271cb018dcd361bd2c917ba7004c3e49d5daf795a2/py_cpuinfo-9.0.0-py3-none-any.whl", hash = "sha256:859625bc251f64e21f077d099d4162689c762b5d6a4c3c97553d56241c9674d5", size = 22335, upload-time = "2022-10-25T20:38:27.636Z" },
]
[[package]]
name = "pygal"
version = "3.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "importlib-metadata" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b0/b6/04176faeb312c84d7f9bc1a810f96ee38d15597e226bb9bda59f3a5cb122/pygal-3.1.0.tar.gz", hash = "sha256:fbdee7351a7423e7907fb8a9c3b77305f6b5678cb2e6fd0db36a8825e42955ec", size = 81006, upload-time = "2025-12-09T10:29:19.587Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/4c/2862dd25352fe4b22ec7760d4fa12cb692587cd7ec3e378cbf644fc0d2a8/pygal-3.1.0-py3-none-any.whl", hash = "sha256:4e923490f3490c90c481f4535fa3adcda20ff374257ab9d8ae897f91b632c0bb", size = 130171, upload-time = "2025-12-09T10:29:16.721Z" },
]
[[package]]
name = "pygaljs"
version = "1.0.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/75/19/3a53f34232a9e6ddad665e71c83693c5db9a31f71785105905c5bc9fbbba/pygaljs-1.0.2.tar.gz", hash = "sha256:0b71ee32495dcba5fbb4a0476ddbba07658ad65f5675e4ad409baf154dec5111", size = 89711, upload-time = "2020-04-03T07:51:44.082Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/49/6f/07dab31ca496feda35cf3455b9e9380c43b5c685bb54ad890831c790da38/pygaljs-1.0.2-py2.py3-none-any.whl", hash = "sha256:d75e18cb21cc2cda40c45c3ee690771e5e3d4652bf57206f20137cf475c0dbe8", size = 91111, upload-time = "2020-04-03T07:51:42.658Z" },
]
[[package]]
name = "pygments"
version = "2.19.2"
@@ -960,26 +915,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
]
[[package]]
name = "pytest-benchmark"
version = "5.2.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "py-cpuinfo" },
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/24/34/9f732b76456d64faffbef6232f1f9dbec7a7c4999ff46282fa418bd1af66/pytest_benchmark-5.2.3.tar.gz", hash = "sha256:deb7317998a23c650fd4ff76e1230066a76cb45dcece0aca5607143c619e7779", size = 341340, upload-time = "2025-11-09T18:48:43.215Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/33/29/e756e715a48959f1c0045342088d7ca9762a2f509b945f362a316e9412b7/pytest_benchmark-5.2.3-py3-none-any.whl", hash = "sha256:bc839726ad20e99aaa0d11a127445457b4219bdb9e80a1afc4b51da7f96b0803", size = 45255, upload-time = "2025-11-09T18:48:39.765Z" },
]
[package.optional-dependencies]
histogram = [
{ name = "pygal" },
{ name = "pygaljs" },
{ name = "setuptools" },
]
[[package]]
name = "pytest-cov"
version = "7.0.0"
@@ -1070,44 +1005,39 @@ wheels = [
[[package]]
name = "sqlalchemy"
version = "2.0.45"
version = "2.0.44"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/be/f9/5e4491e5ccf42f5d9cfc663741d261b3e6e1683ae7812114e7636409fcc6/sqlalchemy-2.0.45.tar.gz", hash = "sha256:1632a4bda8d2d25703fdad6363058d882541bdaaee0e5e3ddfa0cd3229efce88", size = 9869912, upload-time = "2025-12-09T21:05:16.737Z" }
sdist = { url = "https://files.pythonhosted.org/packages/f0/f2/840d7b9496825333f532d2e3976b8eadbf52034178aac53630d09fe6e1ef/sqlalchemy-2.0.44.tar.gz", hash = "sha256:0ae7454e1ab1d780aee69fd2aae7d6b8670a581d8847f2d1e0f7ddfbf47e5a22", size = 9819830, upload-time = "2025-10-10T14:39:12.935Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a2/1c/769552a9d840065137272ebe86ffbb0bc92b0f1e0a68ee5266a225f8cd7b/sqlalchemy-2.0.45-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2e90a344c644a4fa871eb01809c32096487928bd2038bf10f3e4515cb688cc56", size = 2153860, upload-time = "2025-12-10T20:03:23.843Z" },
{ url = "https://files.pythonhosted.org/packages/f3/f8/9be54ff620e5b796ca7b44670ef58bc678095d51b0e89d6e3102ea468216/sqlalchemy-2.0.45-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8c8b41b97fba5f62349aa285654230296829672fc9939cd7f35aab246d1c08b", size = 3309379, upload-time = "2025-12-09T22:06:07.461Z" },
{ url = "https://files.pythonhosted.org/packages/f6/2b/60ce3ee7a5ae172bfcd419ce23259bb874d2cddd44f67c5df3760a1e22f9/sqlalchemy-2.0.45-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:12c694ed6468333a090d2f60950e4250b928f457e4962389553d6ba5fe9951ac", size = 3309948, upload-time = "2025-12-09T22:09:57.643Z" },
{ url = "https://files.pythonhosted.org/packages/a3/42/bac8d393f5db550e4e466d03d16daaafd2bad1f74e48c12673fb499a7fc1/sqlalchemy-2.0.45-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f7d27a1d977a1cfef38a0e2e1ca86f09c4212666ce34e6ae542f3ed0a33bc606", size = 3261239, upload-time = "2025-12-09T22:06:08.879Z" },
{ url = "https://files.pythonhosted.org/packages/6f/12/43dc70a0528c59842b04ea1c1ed176f072a9b383190eb015384dd102fb19/sqlalchemy-2.0.45-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d62e47f5d8a50099b17e2bfc1b0c7d7ecd8ba6b46b1507b58cc4f05eefc3bb1c", size = 3284065, upload-time = "2025-12-09T22:09:59.454Z" },
{ url = "https://files.pythonhosted.org/packages/cf/9c/563049cf761d9a2ec7bc489f7879e9d94e7b590496bea5bbee9ed7b4cc32/sqlalchemy-2.0.45-cp311-cp311-win32.whl", hash = "sha256:3c5f76216e7b85770d5bb5130ddd11ee89f4d52b11783674a662c7dd57018177", size = 2113480, upload-time = "2025-12-09T21:29:57.03Z" },
{ url = "https://files.pythonhosted.org/packages/bc/fa/09d0a11fe9f15c7fa5c7f0dd26be3d235b0c0cbf2f9544f43bc42efc8a24/sqlalchemy-2.0.45-cp311-cp311-win_amd64.whl", hash = "sha256:a15b98adb7f277316f2c276c090259129ee4afca783495e212048daf846654b2", size = 2138407, upload-time = "2025-12-09T21:29:58.556Z" },
{ url = "https://files.pythonhosted.org/packages/2d/c7/1900b56ce19bff1c26f39a4ce427faec7716c81ac792bfac8b6a9f3dca93/sqlalchemy-2.0.45-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3ee2aac15169fb0d45822983631466d60b762085bc4535cd39e66bea362df5f", size = 3333760, upload-time = "2025-12-09T22:11:02.66Z" },
{ url = "https://files.pythonhosted.org/packages/0a/93/3be94d96bb442d0d9a60e55a6bb6e0958dd3457751c6f8502e56ef95fed0/sqlalchemy-2.0.45-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba547ac0b361ab4f1608afbc8432db669bd0819b3e12e29fb5fa9529a8bba81d", size = 3348268, upload-time = "2025-12-09T22:13:49.054Z" },
{ url = "https://files.pythonhosted.org/packages/48/4b/f88ded696e61513595e4a9778f9d3f2bf7332cce4eb0c7cedaabddd6687b/sqlalchemy-2.0.45-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:215f0528b914e5c75ef2559f69dca86878a3beeb0c1be7279d77f18e8d180ed4", size = 3278144, upload-time = "2025-12-09T22:11:04.14Z" },
{ url = "https://files.pythonhosted.org/packages/ed/6a/310ecb5657221f3e1bd5288ed83aa554923fb5da48d760a9f7622afeb065/sqlalchemy-2.0.45-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:107029bf4f43d076d4011f1afb74f7c3e2ea029ec82eb23d8527d5e909e97aa6", size = 3313907, upload-time = "2025-12-09T22:13:50.598Z" },
{ url = "https://files.pythonhosted.org/packages/5c/39/69c0b4051079addd57c84a5bfb34920d87456dd4c90cf7ee0df6efafc8ff/sqlalchemy-2.0.45-cp312-cp312-win32.whl", hash = "sha256:0c9f6ada57b58420a2c0277ff853abe40b9e9449f8d7d231763c6bc30f5c4953", size = 2112182, upload-time = "2025-12-09T21:39:30.824Z" },
{ url = "https://files.pythonhosted.org/packages/f7/4e/510db49dd89fc3a6e994bee51848c94c48c4a00dc905e8d0133c251f41a7/sqlalchemy-2.0.45-cp312-cp312-win_amd64.whl", hash = "sha256:8defe5737c6d2179c7997242d6473587c3beb52e557f5ef0187277009f73e5e1", size = 2139200, upload-time = "2025-12-09T21:39:32.321Z" },
{ url = "https://files.pythonhosted.org/packages/6a/c8/7cc5221b47a54edc72a0140a1efa56e0a2730eefa4058d7ed0b4c4357ff8/sqlalchemy-2.0.45-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe187fc31a54d7fd90352f34e8c008cf3ad5d064d08fedd3de2e8df83eb4a1cf", size = 3277082, upload-time = "2025-12-09T22:11:06.167Z" },
{ url = "https://files.pythonhosted.org/packages/0e/50/80a8d080ac7d3d321e5e5d420c9a522b0aa770ec7013ea91f9a8b7d36e4a/sqlalchemy-2.0.45-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:672c45cae53ba88e0dad74b9027dddd09ef6f441e927786b05bec75d949fbb2e", size = 3293131, upload-time = "2025-12-09T22:13:52.626Z" },
{ url = "https://files.pythonhosted.org/packages/da/4c/13dab31266fc9904f7609a5dc308a2432a066141d65b857760c3bef97e69/sqlalchemy-2.0.45-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:470daea2c1ce73910f08caf10575676a37159a6d16c4da33d0033546bddebc9b", size = 3225389, upload-time = "2025-12-09T22:11:08.093Z" },
{ url = "https://files.pythonhosted.org/packages/74/04/891b5c2e9f83589de202e7abaf24cd4e4fa59e1837d64d528829ad6cc107/sqlalchemy-2.0.45-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9c6378449e0940476577047150fd09e242529b761dc887c9808a9a937fe990c8", size = 3266054, upload-time = "2025-12-09T22:13:54.262Z" },
{ url = "https://files.pythonhosted.org/packages/f1/24/fc59e7f71b0948cdd4cff7a286210e86b0443ef1d18a23b0d83b87e4b1f7/sqlalchemy-2.0.45-cp313-cp313-win32.whl", hash = "sha256:4b6bec67ca45bc166c8729910bd2a87f1c0407ee955df110d78948f5b5827e8a", size = 2110299, upload-time = "2025-12-09T21:39:33.486Z" },
{ url = "https://files.pythonhosted.org/packages/c0/c5/d17113020b2d43073412aeca09b60d2009442420372123b8d49cc253f8b8/sqlalchemy-2.0.45-cp313-cp313-win_amd64.whl", hash = "sha256:afbf47dc4de31fa38fd491f3705cac5307d21d4bb828a4f020ee59af412744ee", size = 2136264, upload-time = "2025-12-09T21:39:36.801Z" },
{ url = "https://files.pythonhosted.org/packages/3d/8d/bb40a5d10e7a5f2195f235c0b2f2c79b0bf6e8f00c0c223130a4fbd2db09/sqlalchemy-2.0.45-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:83d7009f40ce619d483d26ac1b757dfe3167b39921379a8bd1b596cf02dab4a6", size = 3521998, upload-time = "2025-12-09T22:13:28.622Z" },
{ url = "https://files.pythonhosted.org/packages/75/a5/346128b0464886f036c039ea287b7332a410aa2d3fb0bb5d404cb8861635/sqlalchemy-2.0.45-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d8a2ca754e5415cde2b656c27900b19d50ba076aa05ce66e2207623d3fe41f5a", size = 3473434, upload-time = "2025-12-09T22:13:30.188Z" },
{ url = "https://files.pythonhosted.org/packages/cc/64/4e1913772646b060b025d3fc52ce91a58967fe58957df32b455de5a12b4f/sqlalchemy-2.0.45-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f46ec744e7f51275582e6a24326e10c49fbdd3fc99103e01376841213028774", size = 3272404, upload-time = "2025-12-09T22:11:09.662Z" },
{ url = "https://files.pythonhosted.org/packages/b3/27/caf606ee924282fe4747ee4fd454b335a72a6e018f97eab5ff7f28199e16/sqlalchemy-2.0.45-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:883c600c345123c033c2f6caca18def08f1f7f4c3ebeb591a63b6fceffc95cce", size = 3277057, upload-time = "2025-12-09T22:13:56.213Z" },
{ url = "https://files.pythonhosted.org/packages/85/d0/3d64218c9724e91f3d1574d12eb7ff8f19f937643815d8daf792046d88ab/sqlalchemy-2.0.45-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2c0b74aa79e2deade948fe8593654c8ef4228c44ba862bb7c9585c8e0db90f33", size = 3222279, upload-time = "2025-12-09T22:11:11.1Z" },
{ url = "https://files.pythonhosted.org/packages/24/10/dd7688a81c5bc7690c2a3764d55a238c524cd1a5a19487928844cb247695/sqlalchemy-2.0.45-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8a420169cef179d4c9064365f42d779f1e5895ad26ca0c8b4c0233920973db74", size = 3244508, upload-time = "2025-12-09T22:13:57.932Z" },
{ url = "https://files.pythonhosted.org/packages/aa/41/db75756ca49f777e029968d9c9fee338c7907c563267740c6d310a8e3f60/sqlalchemy-2.0.45-cp314-cp314-win32.whl", hash = "sha256:e50dcb81a5dfe4b7b4a4aa8f338116d127cb209559124f3694c70d6cd072b68f", size = 2113204, upload-time = "2025-12-09T21:39:38.365Z" },
{ url = "https://files.pythonhosted.org/packages/89/a2/0e1590e9adb292b1d576dbcf67ff7df8cf55e56e78d2c927686d01080f4b/sqlalchemy-2.0.45-cp314-cp314-win_amd64.whl", hash = "sha256:4748601c8ea959e37e03d13dcda4a44837afcd1b21338e637f7c935b8da06177", size = 2138785, upload-time = "2025-12-09T21:39:39.503Z" },
{ url = "https://files.pythonhosted.org/packages/42/39/f05f0ed54d451156bbed0e23eb0516bcad7cbb9f18b3bf219c786371b3f0/sqlalchemy-2.0.45-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd337d3526ec5298f67d6a30bbbe4ed7e5e68862f0bf6dd21d289f8d37b7d60b", size = 3522029, upload-time = "2025-12-09T22:13:32.09Z" },
{ url = "https://files.pythonhosted.org/packages/54/0f/d15398b98b65c2bce288d5ee3f7d0a81f77ab89d9456994d5c7cc8b2a9db/sqlalchemy-2.0.45-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9a62b446b7d86a3909abbcd1cd3cc550a832f99c2bc37c5b22e1925438b9367b", size = 3475142, upload-time = "2025-12-09T22:13:33.739Z" },
{ url = "https://files.pythonhosted.org/packages/bf/e1/3ccb13c643399d22289c6a9786c1a91e3dcbb68bce4beb44926ac2c557bf/sqlalchemy-2.0.45-py3-none-any.whl", hash = "sha256:5225a288e4c8cc2308dbdd874edad6e7d0fd38eac1e9e5f23503425c8eee20d0", size = 1936672, upload-time = "2025-12-09T21:54:52.608Z" },
{ url = "https://files.pythonhosted.org/packages/e3/81/15d7c161c9ddf0900b076b55345872ed04ff1ed6a0666e5e94ab44b0163c/sqlalchemy-2.0.44-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0fe3917059c7ab2ee3f35e77757062b1bea10a0b6ca633c58391e3f3c6c488dd", size = 2140517, upload-time = "2025-10-10T15:36:15.64Z" },
{ url = "https://files.pythonhosted.org/packages/d4/d5/4abd13b245c7d91bdf131d4916fd9e96a584dac74215f8b5bc945206a974/sqlalchemy-2.0.44-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:de4387a354ff230bc979b46b2207af841dc8bf29847b6c7dbe60af186d97aefa", size = 2130738, upload-time = "2025-10-10T15:36:16.91Z" },
{ url = "https://files.pythonhosted.org/packages/cb/3c/8418969879c26522019c1025171cefbb2a8586b6789ea13254ac602986c0/sqlalchemy-2.0.44-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3678a0fb72c8a6a29422b2732fe423db3ce119c34421b5f9955873eb9b62c1e", size = 3304145, upload-time = "2025-10-10T15:34:19.569Z" },
{ url = "https://files.pythonhosted.org/packages/94/2d/fdb9246d9d32518bda5d90f4b65030b9bf403a935cfe4c36a474846517cb/sqlalchemy-2.0.44-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3cf6872a23601672d61a68f390e44703442639a12ee9dd5a88bbce52a695e46e", size = 3304511, upload-time = "2025-10-10T15:47:05.088Z" },
{ url = "https://files.pythonhosted.org/packages/7d/fb/40f2ad1da97d5c83f6c1269664678293d3fe28e90ad17a1093b735420549/sqlalchemy-2.0.44-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:329aa42d1be9929603f406186630135be1e7a42569540577ba2c69952b7cf399", size = 3235161, upload-time = "2025-10-10T15:34:21.193Z" },
{ url = "https://files.pythonhosted.org/packages/95/cb/7cf4078b46752dca917d18cf31910d4eff6076e5b513c2d66100c4293d83/sqlalchemy-2.0.44-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:70e03833faca7166e6a9927fbee7c27e6ecde436774cd0b24bbcc96353bce06b", size = 3261426, upload-time = "2025-10-10T15:47:07.196Z" },
{ url = "https://files.pythonhosted.org/packages/f8/3b/55c09b285cb2d55bdfa711e778bdffdd0dc3ffa052b0af41f1c5d6e582fa/sqlalchemy-2.0.44-cp311-cp311-win32.whl", hash = "sha256:253e2f29843fb303eca6b2fc645aca91fa7aa0aa70b38b6950da92d44ff267f3", size = 2105392, upload-time = "2025-10-10T15:38:20.051Z" },
{ url = "https://files.pythonhosted.org/packages/c7/23/907193c2f4d680aedbfbdf7bf24c13925e3c7c292e813326c1b84a0b878e/sqlalchemy-2.0.44-cp311-cp311-win_amd64.whl", hash = "sha256:7a8694107eb4308a13b425ca8c0e67112f8134c846b6e1f722698708741215d5", size = 2130293, upload-time = "2025-10-10T15:38:21.601Z" },
{ url = "https://files.pythonhosted.org/packages/62/c4/59c7c9b068e6813c898b771204aad36683c96318ed12d4233e1b18762164/sqlalchemy-2.0.44-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:72fea91746b5890f9e5e0997f16cbf3d53550580d76355ba2d998311b17b2250", size = 2139675, upload-time = "2025-10-10T16:03:31.064Z" },
{ url = "https://files.pythonhosted.org/packages/d6/ae/eeb0920537a6f9c5a3708e4a5fc55af25900216bdb4847ec29cfddf3bf3a/sqlalchemy-2.0.44-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:585c0c852a891450edbb1eaca8648408a3cc125f18cf433941fa6babcc359e29", size = 2127726, upload-time = "2025-10-10T16:03:35.934Z" },
{ url = "https://files.pythonhosted.org/packages/d8/d5/2ebbabe0379418eda8041c06b0b551f213576bfe4c2f09d77c06c07c8cc5/sqlalchemy-2.0.44-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b94843a102efa9ac68a7a30cd46df3ff1ed9c658100d30a725d10d9c60a2f44", size = 3327603, upload-time = "2025-10-10T15:35:28.322Z" },
{ url = "https://files.pythonhosted.org/packages/45/e5/5aa65852dadc24b7d8ae75b7efb8d19303ed6ac93482e60c44a585930ea5/sqlalchemy-2.0.44-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:119dc41e7a7defcefc57189cfa0e61b1bf9c228211aba432b53fb71ef367fda1", size = 3337842, upload-time = "2025-10-10T15:43:45.431Z" },
{ url = "https://files.pythonhosted.org/packages/41/92/648f1afd3f20b71e880ca797a960f638d39d243e233a7082c93093c22378/sqlalchemy-2.0.44-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0765e318ee9179b3718c4fd7ba35c434f4dd20332fbc6857a5e8df17719c24d7", size = 3264558, upload-time = "2025-10-10T15:35:29.93Z" },
{ url = "https://files.pythonhosted.org/packages/40/cf/e27d7ee61a10f74b17740918e23cbc5bc62011b48282170dc4c66da8ec0f/sqlalchemy-2.0.44-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2e7b5b079055e02d06a4308d0481658e4f06bc7ef211567edc8f7d5dce52018d", size = 3301570, upload-time = "2025-10-10T15:43:48.407Z" },
{ url = "https://files.pythonhosted.org/packages/3b/3d/3116a9a7b63e780fb402799b6da227435be878b6846b192f076d2f838654/sqlalchemy-2.0.44-cp312-cp312-win32.whl", hash = "sha256:846541e58b9a81cce7dee8329f352c318de25aa2f2bbe1e31587eb1f057448b4", size = 2103447, upload-time = "2025-10-10T15:03:21.678Z" },
{ url = "https://files.pythonhosted.org/packages/25/83/24690e9dfc241e6ab062df82cc0df7f4231c79ba98b273fa496fb3dd78ed/sqlalchemy-2.0.44-cp312-cp312-win_amd64.whl", hash = "sha256:7cbcb47fd66ab294703e1644f78971f6f2f1126424d2b300678f419aa73c7b6e", size = 2130912, upload-time = "2025-10-10T15:03:24.656Z" },
{ url = "https://files.pythonhosted.org/packages/45/d3/c67077a2249fdb455246e6853166360054c331db4613cda3e31ab1cadbef/sqlalchemy-2.0.44-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ff486e183d151e51b1d694c7aa1695747599bb00b9f5f604092b54b74c64a8e1", size = 2135479, upload-time = "2025-10-10T16:03:37.671Z" },
{ url = "https://files.pythonhosted.org/packages/2b/91/eabd0688330d6fd114f5f12c4f89b0d02929f525e6bf7ff80aa17ca802af/sqlalchemy-2.0.44-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0b1af8392eb27b372ddb783b317dea0f650241cea5bd29199b22235299ca2e45", size = 2123212, upload-time = "2025-10-10T16:03:41.755Z" },
{ url = "https://files.pythonhosted.org/packages/b0/bb/43e246cfe0e81c018076a16036d9b548c4cc649de241fa27d8d9ca6f85ab/sqlalchemy-2.0.44-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2b61188657e3a2b9ac4e8f04d6cf8e51046e28175f79464c67f2fd35bceb0976", size = 3255353, upload-time = "2025-10-10T15:35:31.221Z" },
{ url = "https://files.pythonhosted.org/packages/b9/96/c6105ed9a880abe346b64d3b6ddef269ddfcab04f7f3d90a0bf3c5a88e82/sqlalchemy-2.0.44-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b87e7b91a5d5973dda5f00cd61ef72ad75a1db73a386b62877d4875a8840959c", size = 3260222, upload-time = "2025-10-10T15:43:50.124Z" },
{ url = "https://files.pythonhosted.org/packages/44/16/1857e35a47155b5ad927272fee81ae49d398959cb749edca6eaa399b582f/sqlalchemy-2.0.44-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:15f3326f7f0b2bfe406ee562e17f43f36e16167af99c4c0df61db668de20002d", size = 3189614, upload-time = "2025-10-10T15:35:32.578Z" },
{ url = "https://files.pythonhosted.org/packages/88/ee/4afb39a8ee4fc786e2d716c20ab87b5b1fb33d4ac4129a1aaa574ae8a585/sqlalchemy-2.0.44-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e77faf6ff919aa8cd63f1c4e561cac1d9a454a191bb864d5dd5e545935e5a40", size = 3226248, upload-time = "2025-10-10T15:43:51.862Z" },
{ url = "https://files.pythonhosted.org/packages/32/d5/0e66097fc64fa266f29a7963296b40a80d6a997b7ac13806183700676f86/sqlalchemy-2.0.44-cp313-cp313-win32.whl", hash = "sha256:ee51625c2d51f8baadf2829fae817ad0b66b140573939dd69284d2ba3553ae73", size = 2101275, upload-time = "2025-10-10T15:03:26.096Z" },
{ url = "https://files.pythonhosted.org/packages/03/51/665617fe4f8c6450f42a6d8d69243f9420f5677395572c2fe9d21b493b7b/sqlalchemy-2.0.44-cp313-cp313-win_amd64.whl", hash = "sha256:c1c80faaee1a6c3428cecf40d16a2365bcf56c424c92c2b6f0f9ad204b899e9e", size = 2127901, upload-time = "2025-10-10T15:03:27.548Z" },
{ url = "https://files.pythonhosted.org/packages/9c/5e/6a29fa884d9fb7ddadf6b69490a9d45fded3b38541713010dad16b77d015/sqlalchemy-2.0.44-py3-none-any.whl", hash = "sha256:19de7ca1246fbef9f9d1bff8f1ab25641569df226364a0e40457dc5457c54b05", size = 1928718, upload-time = "2025-10-10T15:29:45.32Z" },
]
[[package]]
@@ -1176,12 +1106,3 @@ sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac8
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "zipp"
version = "3.23.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" },
]