9 Commits
0.0.5 ... 0.1.3

Author SHA1 Message Date
18a0bd77bd Require tests pass before publishing new version
All checks were successful
Run Tests / run-tests (push) Successful in 44s
2026-05-02 15:48:44 +02:00
450533414e Use python from venv
All checks were successful
Run Tests / run-tests (push) Successful in 1m15s
Build & Release / build-docker-image (push) Successful in 2m21s
Build & Release / deploy-to-production (push) Successful in 26s
2026-05-02 12:22:30 +02:00
d665d8e1a0 Revert "Use python from venv"
This reverts commit c1a6dafa9f.
2026-05-01 20:22:45 +02:00
c1a6dafa9f Use python from venv
Some checks failed
Run Tests / run-tests (push) Failing after 39s
2026-05-01 20:21:31 +02:00
0632899f7a Support demo users
Some checks failed
Run Tests / run-tests (push) Failing after 1m2s
2026-05-01 17:41:42 +02:00
a88d1b4e79 Add theme selector
Some checks failed
Run Tests / run-tests (push) Failing after 39s
2026-04-30 21:31:19 +02:00
8ae8bc7a24 Add user color settings
Some checks failed
Run Tests / run-tests (push) Failing after 1m3s
2026-04-30 14:17:09 +02:00
bd9ff7191a Add user specific profiles
Some checks failed
Run Tests / run-tests (push) Failing after 58s
2026-04-28 21:04:52 +02:00
f750cfa8e1 Cleanup config files 2026-04-27 17:23:26 +02:00
31 changed files with 941 additions and 292 deletions

View File

@@ -1,8 +1,10 @@
name: Build & Release name: Build & Release
on: on:
push: workflow_run:
tags: workflows:
- '[0-9]+.[0-9]+.[0-9]+' - "Run Tests"
types:
- completed
env: env:
ENDPOINT: services-3 ENDPOINT: services-3
@@ -14,7 +16,13 @@ env:
jobs: jobs:
build-branch:
runs-on: node20
steps:
- run: echo "${{ gitea.event.workflow_run.head_branch }}"
build-docker-image: build-docker-image:
if: ${{ gitea.event.workflow_run.conclusion == 'success' && startsWith(gitea.event.workflow_run.head_branch, 'refs/tag') }}
runs-on: node20 runs-on: node20
container: container:
image: catthehacker/ubuntu:act-24.04 image: catthehacker/ubuntu:act-24.04
@@ -23,6 +31,7 @@ jobs:
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
ssh-key: ${{ secrets.SSH_JOHN_PRIVATE_KEY }} ssh-key: ${{ secrets.SSH_JOHN_PRIVATE_KEY }}
ref: ${{ gitea.event.workflow_run.head_branch }}
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Login to Devsoap Container Registry - name: Login to Devsoap Container Registry

View File

@@ -1,23 +1,53 @@
name: Run Tests name: Run Tests
on: on:
push: {} push: {}
workflow_dispatch:
env: env:
RUNNER_TOOL_CACHE: /toolcache RUNNER_TOOL_CACHE: /toolcache
jobs: jobs:
run-tests: run-tests:
runs-on: python runs-on: python
container:
image: catthehacker/ubuntu:act-24.04
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
ssh-key: ${{ secrets.SSH_JOHN_PRIVATE_KEY }} ssh-key: ${{ secrets.SSH_JOHN_PRIVATE_KEY }}
- name: Setup Python
id: setup_python
uses: actions/setup-python@v5
with:
python-version-file: 'pyproject.toml'
- name: Restore cached virtualenv
uses: actions/cache/restore@v4
with:
key: venv-${{ runner.os }}-${{ steps.setup_python.outputs.python-version }}-${{ hashFiles('pyproject.toml') }}
path: .venv
- name: Create virtual env
run: |
python3 -m venv .venv
. .venv/bin/activate
which python
python --version
python -m sqlite3 --version
- name: Install poetry - name: Install poetry
run: | run: |
python3 -m pip install poetry==2.3.4 . .venv/bin/activate
pip install poetry==2.3.4
- name: Install the project dependencies - name: Install the project dependencies
run: | run: |
python3 -m poetry install . .venv/bin/activate
python3 -m poetry env info poetry install
poetry env info
- name: Cache virtual env
uses: actions/cache/save@v4
with:
key: venv-${{ runner.os }}-${{ steps.setup_python.outputs.python-version }}-${{ hashFiles('pyproject.toml') }}
path: .venv
- name: Run the automated tests - name: Run the automated tests
run: | run: |
python3 -m poetry run python -m pytest tests . .venv/bin/activate
poetry run pytest tests

View File

@@ -14,12 +14,5 @@ hideWelcomeMessage = true
toolbarMode = "viewer" toolbarMode = "viewer"
showSidebarNavigation = false showSidebarNavigation = false
[theme]
base="light"
backgroundColor = "#eee"
secondaryBackgroundColor = "#fff"
primaryColor = "black"
baseRadius = "none"
[runner] [runner]
magicEnabled = false magicEnabled = false

View File

@@ -13,6 +13,9 @@ ENV PYTHONFAULTHANDLER=1 \
POETRY_CACHE_DIR='/var/cache/pypoetry' \ POETRY_CACHE_DIR='/var/cache/pypoetry' \
POETRY_HOME='/usr/local' POETRY_HOME='/usr/local'
RUN apk update && \
apk upgrade && \
rm -rf /var/cache/apk/*
RUN apk add --no-cache tini RUN apk add --no-cache tini
RUN pip install -Iv --no-cache-dir "poetry==${POETRY_VERSION}" RUN pip install -Iv --no-cache-dir "poetry==${POETRY_VERSION}"

View File

@@ -21,11 +21,12 @@ This is a simple habit tracking web app that allows the user to track occurrence
**Pre-requisites:** [Pip](https://pypi.org/project/pip/) and [Poetry](https://pypi.org/project/poetry/) **Pre-requisites:** [Pip](https://pypi.org/project/pip/) and [Poetry](https://pypi.org/project/poetry/)
1. Clone this repository 1. Clone this repository
2. Install Poetry ``pip install -Iv --no-cache-dir "poetry==2.3.3"`` 2. Create a virtual environment with ``python3 -m venv ./.venv``
3. Install project dependencies ``poetry install`` 3. Activate virtual environment with ``source .venv/bin/activate``
4. Run database migrations ``alembic upgrade head`` 4. Install project dependencies ``poetry install``
5. Run project ``streamlit run app`` 5. Run database migrations ``alembic upgrade head``
6. Project will be running at http://localhost:8501 6. Run project ``streamlit run app``
7. Project will be running at http://localhost:8501
## Run project (Locally with Docker) ## Run project (Locally with Docker)

View File

@@ -8,7 +8,7 @@ from enums import CounterType
@dialog("Add New Counter", icon=":material/add_box:") @dialog("Add New Counter", icon=":material/add_box:")
def _add_counter(): def _add_counter():
colors = crud.get_colors(1) colors = crud.get_colors()
with st.form(key="add_counter", border=False, clear_on_submit=True): with st.form(key="add_counter", border=False, clear_on_submit=True):
title = st.text_input("Title:", key="new_counter_title") title = st.text_input("Title:", key="new_counter_title")
counter_type_name = st.selectbox("Type", options=[e.name for e in CounterType], key="new_counter_type") counter_type_name = st.selectbox("Type", options=[e.name for e in CounterType], key="new_counter_type")

67
app/pages/settings.py Normal file
View File

@@ -0,0 +1,67 @@
import streamlit as st
import queries.crud as crud
import themes as th
from user import is_logged_in, is_login_enabled
st.title("Settings")
if hasattr(st.session_state, 'user_name') and st.session_state.user_name:
st.markdown(f"Currently logged in as **{st.session_state.user_name}**")
st.header("Theme")
themes = st.session_state.themes
with st.container(horizontal=True, width="stretch"):
for theme in themes.keys():
if st.button(label=themes[theme]["button_face_label"],
icon = themes[theme]["button_face_icon"],
disabled = (theme == st.session_state.current_theme),
width = "stretch"):
th.change_theme(theme)
st.rerun()
st.header("Colors")
with st.container(key="settings-color-selector"):
palettes = crud.get_color_palettes()
selected = crud.get_color_palette()
for palette in palettes.iterrows():
id = palette[1]['id']
name = palette[1]['name']
color1 = palette[1]['color1']
color2 = palette[1]['color2']
color3 = palette[1]['color3']
color4 = palette[1]['color4']
if selected == id:
with st.container(horizontal=True, key=f"settings-color-selector-selected"):
st.button(f"{name} **(selected)**", disabled=True, width="stretch", icon=":material/radio_button_checked:")
st.html(f"""
<span class="settings-color-selector-colors">
<span style="background-color:#{color1}">&nbsp;</span>
<span style="background-color:#{color2}">&nbsp;</span>
<span style="background-color:#{color3}">&nbsp;</span>
<span style="background-color:#{color4}">&nbsp;</span>
</span>
""", width=400)
else:
with st.container(horizontal=True):
if st.button(f"{name}", width="stretch", icon=":material/radio_button_unchecked:"):
crud.set_color_palette(id)
st.rerun()
st.html(f"""
<span class="settings-color-selector-colors">
<span style="background-color:#{color1}">&nbsp;</span>
<span style="background-color:#{color2}">&nbsp;</span>
<span style="background-color:#{color3}">&nbsp;</span>
<span style="background-color:#{color4}">&nbsp;</span>
</span>
""", width=400)
if is_login_enabled():
st.header("Actions")
with st.container():
if is_logged_in():
if st.button("Logout", icon=":material/logout:", width="stretch"):
st.logout()

View File

@@ -29,7 +29,10 @@ if "counter_id" in st.query_params.keys():
with st.container(horizontal_alignment="right", vertical_alignment="bottom", horizontal=True): with st.container(horizontal_alignment="right", vertical_alignment="bottom", horizontal=True):
st.header('Counter: ' + df['name']) st.header('Counter: ' + df['name'])
selection = st.segmented_control("Time Range", options, selection_mode="single", required=True, default=counter_type.name, label_visibility="hidden") selected = counter_type.name
if selected == CounterType.SIMPLE.name:
selected = CounterType.DAILY.name
selection = st.segmented_control("Time Range", options, selection_mode="single", required=True, default=selected, label_visibility="hidden")
match getattr(CounterType, selection): match getattr(CounterType, selection):
case CounterType.DAILY: case CounterType.DAILY:

View File

@@ -2,10 +2,11 @@ from os import getenv
import streamlit as st import streamlit as st
from sqlalchemy.sql import text from sqlalchemy.sql import text
from streamlit.connections import BaseConnection from streamlit.connections import SQLConnection
def connection() -> BaseConnection:
_connection = st.connection("sql", url=getenv('DATABASE_URL')) def connection() -> SQLConnection:
_connection = st.connection("sql", url=getenv('DATABASE_URL'), ttl=0, autocommit=True)
with _connection.session as configured_session: with _connection.session as configured_session:
configured_session.execute(text('PRAGMA foreign_keys=ON')) configured_session.execute(text('PRAGMA foreign_keys=ON'))
return _connection return _connection

View File

@@ -7,56 +7,143 @@ from enums import CounterType
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def create_counter(title:str, counter_type:CounterType, counter_color) -> None: def create_counter(title:str, counter_type:CounterType, counter_color) -> None:
logger.info("Adding counter %s", counter_type) user_id = int(st.session_state.user_id)
logger.info("Adding counter %s for user %d", counter_type, user_id)
with connection().session as session: with connection().session as session:
try: try:
query = text('INSERT INTO counters (name, type, color) VALUES (:title, :type, :color)') query = text('INSERT INTO counters (user_id, name, type, color) VALUES (:user, :title, :type, :color)')
session.execute(query, {'title': title, 'type': counter_type, 'color': counter_color}) session.execute(query, {
session.commit() 'user': user_id,
'title': title,
'type': counter_type,
'color': counter_color
})
except Exception as e: except Exception as e:
logger.error(e) logger.error(e)
session.rollback() session.rollback()
def get_counters(): def get_counters():
user_id = int(st.session_state.user_id)
try: try:
return connection().query('SELECT id, name, type, color FROM counters', ttl=0) return connection().query("""
SELECT id, name, type, color
FROM counters
WHERE user_id = :user
""", params={'user': user_id })
except Exception as e: except Exception as e:
logger.error(e) logger.error(e)
return st.dataframe() return st.dataframe()
def increment_counter(counter_id:int) -> None: def increment_counter(counter_id:int) -> None:
logger.info("Incrementing counter %s", counter_id) user_id = int(st.session_state.user_id)
logger.info("Incrementing counter %d for user %d", counter_id, user_id)
with connection().session as session: with connection().session as session:
try: try:
query = text('INSERT INTO entries (counter_id) VALUES (:id)') query = text('INSERT INTO entries (counter_id, user_id) VALUES (:id, :user)')
session.execute(query, {'id': counter_id}) session.execute(query, {
session.commit() 'id': counter_id,
'user': user_id
})
except Exception as e: except Exception as e:
logger.error(e) logger.error(e)
session.rollback() session.rollback()
def remove_counter(counter_id:int) -> None: def remove_counter(counter_id:int) -> None:
logger.info("Removing counter %s", counter_id) user_id = int(st.session_state.user_id)
logger.info("Removing counter %d from user %d", counter_id, user_id)
with connection().session as session: with connection().session as session:
try: try:
query = text('DELETE FROM counters WHERE id = :id') query = text('DELETE FROM counters WHERE id = :id AND user_id = :user')
session.execute(query, {'id': counter_id}) session.execute(query, {
session.commit() 'id': counter_id,
'user': user_id
})
except Exception as e: except Exception as e:
logger.error(e) logger.error(e)
session.rollback() session.rollback()
def get_counter(counter_id:int): def get_counter(counter_id:int):
user_id = int(st.session_state.user_id)
try: try:
return connection().query('SELECT * FROM counters WHERE id = :id', params={'id': counter_id}, ttl=0).iloc[0] counters = connection().query("""
SELECT * FROM counters
WHERE id = :id AND user_id = :user
""", params={ 'id': counter_id, 'user': user_id}
)
if counters.empty:
return None
return counters.iloc[0]
except Exception as e: except Exception as e:
logger.error(e) logger.error(e)
return None return None
def get_colors(palette_id:int): def get_color_palettes():
try: try:
return connection().query('''SELECT color1,color2,color3,color4,color5 FROM color_palettes WHERE id = :id''', params={'id': palette_id}) return connection().query('SELECT * FROM color_palettes''')
except Exception as e: except Exception as e:
logger.error(e) logger.error(e)
return None return None
def get_color_palette() -> int:
user_id = int(st.session_state.user_id)
try:
return int(connection().query('SELECT color_palette_id FROM users WHERE id = :id''', params={'id': user_id})['color_palette_id'][0])
except Exception as e:
logger.error(e)
return None
def set_color_palette(palette_id:int):
user_id = int(st.session_state.user_id)
logger.info("Changing palette for user %d to %d", user_id, palette_id)
with connection().session as session:
try:
query = text('UPDATE users SET color_palette_id = :palette WHERE id = :user')
session.execute(query, {
'palette': palette_id,
'user': user_id
})
except Exception as e:
logger.error(e)
session.rollback()
def get_colors():
user_id = int(st.session_state.user_id)
try:
return connection().query('''
SELECT color1,color2,color3,color4,color5
FROM users u
LEFT JOIN color_palettes p ON p.id = u.color_palette_id
WHERE u.id = :id
''', params={'id': user_id})
except Exception as e:
logger.error(e)
return None
def set_theme(theme:str):
user_id = int(st.session_state.user_id)
logger.info("Changing theme for user %d to %s", user_id, theme)
with connection().session as session:
try:
query = text('UPDATE users SET theme = :theme WHERE id = :user')
session.execute(query, {
'theme': theme,
'user': user_id
})
except Exception as e:
logger.error(e)
session.rollback()
def get_theme() -> str:
user_id = int(st.session_state.user_id)
try:
return connection().query('SELECT theme FROM users u WHERE u.id = :id', params={'id': user_id})['theme'][0]
except Exception as e:
logger.error(e)
return None

View File

@@ -1,9 +1,11 @@
import logging import logging
from queries.connection import connection from queries.connection import connection
import streamlit as st
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def get_all_daily_analytics(end_date:str = 'now'): def get_all_daily_analytics(end_date:str = 'now'):
user_id = int(st.session_state.user_id)
try: try:
return connection().query(''' return connection().query('''
WITH RECURSIVE timeseries(d) AS ( WITH RECURSIVE timeseries(d) AS (
@@ -19,6 +21,7 @@ def get_all_daily_analytics(end_date:str = 'now'):
counter_id, counter_id,
sum(increment) as count sum(increment) as count
FROM entries FROM entries
WHERE user_id = :user_id
group by counter_id, date(timestamp) group by counter_id, date(timestamp)
) )
select select
@@ -31,13 +34,14 @@ def get_all_daily_analytics(end_date:str = 'now'):
left outer join stats t on s.d = t.d left outer join stats t on s.d = t.d
left join counters c on t.counter_id = c.id left join counters c on t.counter_id = c.id
GROUP by s.d GROUP by s.d
''', params={"end_date": end_date}, ttl=0) ''', params={"end_date": end_date, "user_id": user_id })
except Exception as e: except Exception as e:
logger.error(e) logger.error(e)
return None return None
def get_daily_analytics(counter_id:int, end_date:str = 'now'): def get_daily_analytics(counter_id:int, end_date:str = 'now'):
user_id = int(st.session_state.user_id)
try: try:
return connection().query(''' return connection().query('''
WITH RECURSIVE timeseries(d) AS ( WITH RECURSIVE timeseries(d) AS (
@@ -53,6 +57,7 @@ def get_daily_analytics(counter_id:int, end_date:str = 'now'):
sum(increment) as count sum(increment) as count
FROM entries FROM entries
where counter_id = :id where counter_id = :id
and user_id = :user_id
group by date(timestamp) group by date(timestamp)
) )
SELECT SELECT
@@ -60,7 +65,7 @@ def get_daily_analytics(counter_id:int, end_date:str = 'now'):
coalesce(s.count, 0) as count coalesce(s.count, 0) as count
FROM timeseries as t FROM timeseries as t
LEFT JOIN stats as s on s.d = t.d LEFT JOIN stats as s on s.d = t.d
''', params={'id': counter_id, "end_date": end_date}, ttl=0) ''', params={'id': counter_id, "end_date": end_date, "user_id": user_id})
except Exception as e: except Exception as e:
logger.error(e) logger.error(e)
return None return None

View File

@@ -1,9 +1,11 @@
import logging import logging
from queries.connection import connection from queries.connection import connection
import streamlit as st
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def get_all_monthly_analytics(end_date:str = 'now'): def get_all_monthly_analytics(end_date:str = 'now'):
user_id = int(st.session_state.user_id)
try: try:
return connection().query(''' return connection().query('''
WITH RECURSIVE timeseries(d) AS ( WITH RECURSIVE timeseries(d) AS (
@@ -26,6 +28,7 @@ def get_all_monthly_analytics(end_date:str = 'now'):
counter_id, counter_id,
sum(increment) as count sum(increment) as count
FROM entries FROM entries
WHERE user_id = :user_id
group by counter_id, strftime('%m', timestamp), strftime('%Y', timestamp) group by counter_id, strftime('%m', timestamp), strftime('%Y', timestamp)
) )
select select
@@ -38,12 +41,13 @@ def get_all_monthly_analytics(end_date:str = 'now'):
left outer join stats t on m.m = t.m and m.y = t.y left outer join stats t on m.m = t.m and m.y = t.y
left join counters c on t.counter_id = c.id left join counters c on t.counter_id = c.id
GROUP by m.m, m.y GROUP by m.m, m.y
''', params={"end_date": end_date}, ttl=0) ''', params={"end_date": end_date, "user_id": user_id})
except Exception as e: except Exception as e:
logger.error(e) logger.error(e)
return None return None
def get_monthly_analytics(counter_id:int, end_date:str = 'now'): def get_monthly_analytics(counter_id:int, end_date:str = 'now'):
user_id = int(st.session_state.user_id)
try: try:
return connection().query(''' return connection().query('''
WITH RECURSIVE timeseries(d) AS ( WITH RECURSIVE timeseries(d) AS (
@@ -66,6 +70,7 @@ def get_monthly_analytics(counter_id:int, end_date:str = 'now'):
sum(increment) as count sum(increment) as count
FROM entries FROM entries
where counter_id = :id where counter_id = :id
and user_id = :user_id
group by strftime('%m', timestamp), strftime('%Y', timestamp) group by strftime('%m', timestamp), strftime('%Y', timestamp)
) )
SELECT SELECT
@@ -73,7 +78,7 @@ def get_monthly_analytics(counter_id:int, end_date:str = 'now'):
coalesce(s.count, 0) as count coalesce(s.count, 0) as count
FROM months as m FROM months as m
LEFT JOIN stats as s on s.m = m.m and s.y = m.y LEFT JOIN stats as s on s.m = m.m and s.y = m.y
''', params={'id': counter_id, "end_date": end_date}, ttl=0) ''', params={'id': counter_id, "end_date": end_date, "user_id": user_id})
except Exception as e: except Exception as e:
logger.error(e) logger.error(e)
return None return None

40
app/queries/user.py Normal file
View File

@@ -0,0 +1,40 @@
import logging
import streamlit as st
from pandas import DataFrame
from sqlalchemy.sql import text
from streamlit.user_info import UserInfoProxy
from queries.connection import connection
logger = logging.getLogger(__name__)
def find_user_by_oidc_id(oidc_user_id):
return connection().query('SELECT * FROM users WHERE oidc_user_id = :id', params={'id': oidc_user_id})
def find_user_by_email(email):
return connection().query('SELECT * FROM users WHERE email = :email', params={'email': email})
def find_default_user():
return find_user_by_email('default')
def update_default_user(email, name, oidc_user_id):
with connection().session as session:
try:
query = text("UPDATE users SET email = :email, name = :name, oidc_user_id = :user_id WHERE email = 'default'")
session.execute(query, {'email': email, 'name': name, 'user_id': oidc_user_id})
except Exception as e:
session.rollback()
raise e
def create_user(email, name, oidc_user_id) -> DataFrame:
with connection().session as session:
try:
logger.info("Creating new user %s", email)
query = text('INSERT INTO users (email, name, oidc_user_id) VALUES (:email, :name, :user_id) RETURNING *')
return DataFrame(session.execute(query, {'email': email, 'name': name, 'user_id': oidc_user_id}))
except Exception as e:
session.rollback()
raise e

View File

@@ -1,9 +1,11 @@
import logging import logging
import streamlit as st
from queries.connection import connection from queries.connection import connection
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def get_all_weekly_analytics(end_date:str = 'now'): def get_all_weekly_analytics(end_date:str = 'now'):
user_id = int(st.session_state.user_id)
try: try:
return connection().query(''' return connection().query('''
WITH RECURSIVE timeseries(d) AS ( WITH RECURSIVE timeseries(d) AS (
@@ -23,6 +25,7 @@ def get_all_weekly_analytics(end_date:str = 'now'):
counter_id, counter_id,
sum(increment) as count sum(increment) as count
FROM entries FROM entries
WHERE user_id = :user_id
group by counter_id, strftime('%W', timestamp) group by counter_id, strftime('%W', timestamp)
) )
select select
@@ -35,12 +38,13 @@ def get_all_weekly_analytics(end_date:str = 'now'):
left outer join stats t on s.w = t.w left outer join stats t on s.w = t.w
left join counters c on t.counter_id = c.id left join counters c on t.counter_id = c.id
GROUP by s.w GROUP by s.w
''', params={"end_date": end_date}, ttl=0) ''', params={"end_date": end_date, "user_id": user_id})
except Exception as e: except Exception as e:
logger.error(e) logger.error(e)
return None return None
def get_weekly_analytics(counter_id:int, end_date:str = 'now'): def get_weekly_analytics(counter_id:int, end_date:str = 'now'):
user_id = int(st.session_state.user_id)
try: try:
return connection().query(''' return connection().query('''
WITH RECURSIVE timeseries(d) AS ( WITH RECURSIVE timeseries(d) AS (
@@ -60,6 +64,7 @@ def get_weekly_analytics(counter_id:int, end_date:str = 'now'):
sum(increment) as count sum(increment) as count
FROM entries FROM entries
where counter_id = :id where counter_id = :id
and user_id = :user_id
group by strftime('%W', timestamp) group by strftime('%W', timestamp)
) )
SELECT SELECT
@@ -67,7 +72,7 @@ def get_weekly_analytics(counter_id:int, end_date:str = 'now'):
coalesce(s.count, 0) as count coalesce(s.count, 0) as count
FROM weeks as w FROM weeks as w
LEFT JOIN stats as s on s.w = w.w LEFT JOIN stats as s on s.w = w.w
''', params={'id': counter_id, "end_date": end_date}, ttl=0) ''', params={'id': counter_id, "end_date": end_date, "user_id": user_id})
except Exception as e: except Exception as e:
logger.error(e) logger.error(e)
return None return None

View File

@@ -1,9 +1,11 @@
import logging import logging
import streamlit as st
from queries.connection import connection from queries.connection import connection
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def get_all_yearly_analytics(end_date:str = 'now'): def get_all_yearly_analytics(end_date:str = 'now'):
user_id = int(st.session_state.user_id)
try: try:
return connection().query(''' return connection().query('''
WITH RECURSIVE timeseries(d) AS ( WITH RECURSIVE timeseries(d) AS (
@@ -23,6 +25,7 @@ def get_all_yearly_analytics(end_date:str = 'now'):
counter_id, counter_id,
sum(increment) as count sum(increment) as count
FROM entries FROM entries
WHERE user_id = :user_id
group by counter_id, strftime('%Y', timestamp) group by counter_id, strftime('%Y', timestamp)
) )
select select
@@ -35,12 +38,13 @@ def get_all_yearly_analytics(end_date:str = 'now'):
left outer join stats t on y.y = t.y left outer join stats t on y.y = t.y
left join counters c on t.counter_id = c.id left join counters c on t.counter_id = c.id
GROUP by y.y GROUP by y.y
''', params={"end_date": end_date}, ttl=0) ''', params={"end_date": end_date, "user_id": user_id})
except Exception as e: except Exception as e:
logger.error(e) logger.error(e)
return None return None
def get_yearly_analytics(counter_id:int, end_date:str = 'now'): def get_yearly_analytics(counter_id:int, end_date:str = 'now'):
user_id = int(st.session_state.user_id)
try: try:
return connection().query(''' return connection().query('''
WITH RECURSIVE timeseries(d) AS ( WITH RECURSIVE timeseries(d) AS (
@@ -60,6 +64,7 @@ def get_yearly_analytics(counter_id:int, end_date:str = 'now'):
sum(increment) as count sum(increment) as count
FROM entries FROM entries
where counter_id = :id where counter_id = :id
and user_id = :user_id
group by strftime('%Y', timestamp) group by strftime('%Y', timestamp)
) )
SELECT SELECT
@@ -67,7 +72,7 @@ def get_yearly_analytics(counter_id:int, end_date:str = 'now'):
coalesce(s.count, 0) as count coalesce(s.count, 0) as count
FROM years as m FROM years as m
LEFT JOIN stats as s on s.y = m.y LEFT JOIN stats as s on s.y = m.y
''', params={'id': counter_id, "end_date": end_date}, ttl=0) ''', params={'id': counter_id, "end_date": end_date, "user_id":user_id})
except Exception as e: except Exception as e:
logger.error(e) logger.error(e)
return None return None

View File

@@ -1,21 +1,33 @@
import streamlit as st import streamlit as st
import logging from streamlit import dialog
import queries.user as user_queries
import random
from logger import init_logger from logger import init_logger
from styles import init_styles from styles import init_styles
from user import init_user, is_login_enabled, is_logged_in, init_demo_user
from themes import init_themes
init_logger() init_logger()
init_user()
init_styles() init_styles()
init_themes()
if hasattr(st, 'user') and hasattr(st.user, 'is_logged_in'): if not is_login_enabled() or is_logged_in():
if not st.user.is_logged_in:
with st.container(width="stretch", height="stretch", horizontal_alignment="center"):
st.title("Daily Counter", width="stretch", text_alignment="center")
st.text("Please log in to use this app", width="stretch", text_alignment="center")
st.space()
if st.button("Log in"):
st.login()
else:
counters = st.Page("pages/counters.py", title="Counters", icon=":material/update:") counters = st.Page("pages/counters.py", title="Counters", icon=":material/update:")
stats = st.Page("pages/stats.py", title="Statistics", icon=":material/chart_data:") stats = st.Page("pages/stats.py", title="Statistics", icon=":material/chart_data:")
pg = st.navigation(position="top", pages=[counters, stats]) settings = st.Page("pages/settings.py", title="&nbsp;", icon=":material/menu:")
pg.run() pages = [counters, stats, settings]
pg = st.navigation(position="top", pages=pages)
pg.run()
else:
with st.container(width="stretch", height="stretch", horizontal_alignment="center"):
st.title("Daily Counter", width="stretch", text_alignment="center")
st.text("Please log in to use this app", width="stretch", text_alignment="center")
st.space()
if st.button("Log in", width="stretch",icon=":material/login:"):
st.login()
if st.button("Demo", width="stretch", icon=":material/account_box:"):
init_demo_user()
st.rerun()

View File

@@ -1,6 +1,6 @@
import streamlit as st import streamlit as st
import queries
from queries import crud from queries import crud
from user import is_logged_in
def _load_css(filepath): def _load_css(filepath):
with open(filepath) as file: with open(filepath) as file:
@@ -8,16 +8,17 @@ def _load_css(filepath):
def _load_color_selector_styles(): def _load_color_selector_styles():
colors = crud.get_colors(1) #FIXME Change to use user profile color palette if is_logged_in():
for idx, c in enumerate(colors.keys()): colors = crud.get_colors()
css_color = '#' + colors[c][0] for idx, c in enumerate(colors.keys()):
st.html(f""" css_color = '#' + colors[c][0]
<style> st.html(f"""
.st-key-new_counter_color_selector label:has(> input[value='{idx}']) {{ <style>
background-color: {css_color}; .st-key-new_counter_color_selector label:has(> input[value='{idx}']) {{
}} background-color: {css_color};
</style> }}
""") </style>
""")
def init_styles(): def init_styles():

39
app/themes.py Normal file
View File

@@ -0,0 +1,39 @@
import streamlit as st
from queries import crud
from user import is_logged_in, is_login_enabled
def init_themes():
if 'themes' not in st.session_state:
st.session_state.themes = {
"light": {
"theme.base": "light",
"theme.backgroundColor": "white",
"theme.primaryColor": "#5591f5",
"theme.textColor": "#0a1464",
"button_face_label": "Light",
"button_face_icon": ":material/light_mode:"
},
"dark": {
"theme.base": "dark",
"theme.backgroundColor": "black",
"theme.primaryColor": "#c98bdb",
"theme.textColor": "white",
"button_face_label": "Dark",
"button_face_icon": ":material/dark_mode:"
}
}
if 'current_theme' not in st.session_state:
st.session_state.current_theme = 'light'
change_theme('light')
def change_theme(theme):
if is_logged_in():
crud.set_theme(theme)
st.session_state.current_theme = theme
for key, val in st.session_state.themes[theme].items():
if key.startswith("theme"):
st._config.set_option(key, val)

60
app/user.py Normal file
View File

@@ -0,0 +1,60 @@
import logging
import random
import queries.user as user_queries
import streamlit as st
from pandas import DataFrame
from streamlit.user_info import UserInfoProxy
logger = logging.getLogger(__name__)
def is_login_enabled() -> bool:
return hasattr(st, 'user') and hasattr(st.user, 'is_logged_in')
def is_demo_user() -> bool:
return hasattr(st.session_state, 'user_is_demo') and st.session_state.user_is_demo
def is_logged_in() -> bool:
return not is_login_enabled() or is_demo_user() or (is_login_enabled() and st.user.is_logged_in)
def init_user():
if not is_demo_user():
set_user_in_session(st.user)
def set_user_in_session(user: UserInfoProxy):
email = user.email if hasattr(user, "email") else None
user_id = user.sub if hasattr(user, "sub") else None
name = user.name if hasattr(user, "name") else None
user_entity = user_queries.find_user_by_oidc_id(user_id) if user_id else DataFrame()
if user_entity.empty:
user_entity = user_queries.find_user_by_email(email) if email else DataFrame()
if user_entity.empty:
user_entity = user_queries.find_default_user()
if user_entity.empty and email and name and user_id:
user_entity = user_queries.create_user(email, name, user_id)
elif name:
user_queries.update_default_user(email, name, user_id)
user_entity = user_queries.find_user_by_oidc_id(user_id)
if not user_entity.empty:
st.session_state.user_id = int(user_entity["id"][0])
st.session_state.user_name = user_entity["name"][0]
st.session_state.user_email = user_entity["email"][0]
st.session_state.user_external_id = user_entity["oidc_user_id"][0]
st.session_state.current_theme = user_entity["theme"][0]
st.session_state.user_is_demo = False
else:
logger.warning("No active user found!")
def init_demo_user():
demo_id = ''.join(random.choice('0123456789abcdefghijklmnopqrstuvwxyz') for i in range(6))
demo_user = user_queries.create_user(f"demo+{demo_id}@internal", f"Demo user {demo_id}", None)
st.session_state.user_id = int(demo_user["id"][0])
st.session_state.user_name = demo_user["name"][0]
st.session_state.user_email = demo_user["email"][0]
st.session_state.user_external_id = demo_user["oidc_user_id"][0]
st.session_state.current_theme = demo_user["theme"][0]
st.session_state.user_is_demo = True

View File

@@ -1,5 +1,5 @@
#MainMenu { #MainMenu {
#display: none; display: none;
} }
.stApp { .stApp {
min-width: 360px; min-width: 360px;
@@ -15,7 +15,6 @@
} }
.stPageLink a { .stPageLink a {
background: whitesmoke;
height: 40px; height: 40px;
width: 45px; width: 45px;
border: 1px solid silver; border: 1px solid silver;
@@ -34,7 +33,6 @@
border: 1px solid gray; border: 1px solid gray;
padding: 10px; padding: 10px;
border-radius: 5px; border-radius: 5px;
background-color: whitesmoke;
} }
.st-key-new_counter_color_selector div[role = "radiogroup"] { .st-key-new_counter_color_selector div[role = "radiogroup"] {
@@ -54,3 +52,45 @@
visibility: hidden; visibility: hidden;
} }
div:has(> .stToolbarActions) {
display: none;
}
.rc-overflow > .rc-overflow-item {
flex:1;
}
.rc-overflow > .rc-overflow-item:nth-child(3) {
margin-left: auto;
flex: 0;
}
.rc-overflow > .rc-overflow-item:nth-child(3) > div {
width: 33px;
}
.rc-overflow > .rc-overflow-item:nth-child(3) a {
border-radius: 13px;
}
.rc-overflow > .rc-overflow-item-rest {
display: none;
}
.st-key-settings-color-selector {
gap: 3px;
}
.settings-color-selector-colors > span {
display:inline-block;
width:2.5em;
height:2.5em;
}
.st-key-settings-color-selector button {
border: 0;
background-color: transparent;
}
.st-key-settings-color-selector button > div {
justify-content: left;
}
.settings-color-selector-colors {
float: right;
}
.st-key-settings-color-selector-selected .settings-color-selector-colors > span {
opacity: 0.5;
}

View File

@@ -0,0 +1,56 @@
"""add user id
Revision ID: d9faf8fb8642
Revises: 4ee21f978e6c
Create Date: 2026-04-27 17:24:17.892586
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'd9faf8fb8642'
down_revision: Union[str, Sequence[str], None] = '4ee21f978e6c'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
users = op.create_table(
"users",
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
sa.Column("email", sa.String, nullable=False),
sa.Column("name", sa.String),
sa.Column("oidc_user_id", sa.Integer)
)
op.bulk_insert(users, [ { "email": "default" } ])
with op.batch_alter_table("counters") as batch_op:
batch_op.add_column(sa.Column("user_id", sa.Integer, insert_default=1))
batch_op.create_foreign_key("fk_counters_user_id",
referent_table="users",
remote_cols=['id'],
local_cols=['user_id'])
with op.batch_alter_table("entries") as batch_op:
batch_op.add_column(sa.Column("user_id", sa.Integer, insert_default=1))
batch_op.create_foreign_key("fk_entries_user_id",
referent_table="users",
remote_cols=['id'],
local_cols=['user_id'])
def downgrade() -> None:
with op.batch_alter_table("counters") as batch_op:
batch_op.drop_constraint("fk_counters_user_id", type_="foreignkey")
batch_op.drop_column('user_id')
with op.batch_alter_table("entries") as batch_op:
batch_op.drop_constraint('fk_entries_user_id', type_='foreignkey')
batch_op.drop_column('user_id')
op.drop_table("users")

View File

@@ -0,0 +1,55 @@
"""personalized colors
Revision ID: 720abfadcd44
Revises: d9faf8fb8642
Create Date: 2026-04-30 10:48:24.595774
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '720abfadcd44'
down_revision: Union[str, Sequence[str], None] = 'd9faf8fb8642'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.drop_table("color_palettes")
color_palette = op.create_table(
"color_palettes",
sa.Column('id', sa.Integer, primary_key=True, autoincrement=True),
sa.Column('name', sa.String, nullable=False),
sa.Column('color1', sa.String(6), nullable=False),
sa.Column('color2', sa.String(6), nullable=False),
sa.Column('color3', sa.String(6), nullable=False),
sa.Column('color4', sa.String(6), nullable=False),
sa.Column('color5', sa.String(6), nullable=False),
)
op.bulk_insert(color_palette, [
{"name": "Flames", "color1": "F2F3AE", "color2": "EDD382", "color3": "FC9E4F", "color4": "FF521B", "color5": "020122"},
{"name": "Water", "color1": "2B4141", "color2": "0EB1D2", "color3": "34E4EA", "color4": "8AB9B5", "color5": "C8C2AE"},
{"name": "Nature", "color1": "181F1C", "color2": "274029", "color3": "315C2B", "color4": "60712F", "color5": "9EA93F"},
{"name": "Mellow", "color1": "A3A380", "color2": "D6CE93", "color3": "EFEBCE", "color4": "D8A48F", "color5": "BB8588"},
{"name": "Light Blue", "color1": "32292F", "color2": "99E1D9", "color3": "F0F7F4", "color4": "70ABAF", "color5": "705D56"}
])
with op.batch_alter_table("users") as batch_op:
batch_op.add_column(sa.Column("color_palette_id", sa.Integer, nullable=False, server_default="1"))
batch_op.create_foreign_key("fk_users_color_palettes_id",
referent_table="color_palettes",
local_cols=["color_palette_id"],
remote_cols=["id"])
def downgrade() -> None:
with op.batch_alter_table("users") as batch_op:
batch_op.drop_constraint("fk_users_color_palettes_id", type_="foreignkey")
batch_op.drop_column("color_palette_id")
op.drop_column("color_palettes", "name")

View File

@@ -0,0 +1,25 @@
"""theme
Revision ID: 8be315e8a5dc
Revises: 720abfadcd44
Create Date: 2026-04-30 18:12:43.026620
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '8be315e8a5dc'
down_revision: Union[str, Sequence[str], None] = '720abfadcd44'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('users', sa.Column('theme', sa.String(), nullable=False, server_default='light'))
def downgrade() -> None:
op.drop_column('users', 'theme')

296
poetry.lock generated
View File

@@ -111,14 +111,14 @@ files = [
[[package]] [[package]]
name = "certifi" name = "certifi"
version = "2026.2.25" version = "2026.4.22"
description = "Python package for providing Mozilla's CA Bundle." description = "Python package for providing Mozilla's CA Bundle."
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
groups = ["main"] groups = ["main"]
files = [ files = [
{file = "certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa"}, {file = "certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a"},
{file = "certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7"}, {file = "certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580"},
] ]
[[package]] [[package]]
@@ -360,14 +360,14 @@ files = [
[[package]] [[package]]
name = "click" name = "click"
version = "8.3.2" version = "8.3.3"
description = "Composable command line interface toolkit" description = "Composable command line interface toolkit"
optional = false optional = false
python-versions = ">=3.10" python-versions = ">=3.10"
groups = ["main"] groups = ["main"]
files = [ files = [
{file = "click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d"}, {file = "click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613"},
{file = "click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5"}, {file = "click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2"},
] ]
[package.dependencies] [package.dependencies]
@@ -379,70 +379,70 @@ version = "0.4.6"
description = "Cross-platform colored terminal text." description = "Cross-platform colored terminal text."
optional = false optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
groups = ["main", "dev"] groups = ["main"]
markers = "platform_system == \"Windows\" or sys_platform == \"win32\""
files = [ files = [
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
] ]
markers = {main = "platform_system == \"Windows\" or sys_platform == \"win32\"", dev = "sys_platform == \"win32\""}
[[package]] [[package]]
name = "cryptography" name = "cryptography"
version = "46.0.7" version = "47.0.0"
description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
optional = false optional = false
python-versions = "!=3.9.0,!=3.9.1,>=3.8" python-versions = "!=3.9.0,!=3.9.1,>=3.8"
groups = ["main"] groups = ["main"]
files = [ files = [
{file = "cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4"}, {file = "cryptography-47.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:160ad728f128972d362e714054f6ba0067cab7fb350c5202a9ae8ae4ce3ef1a0"},
{file = "cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325"}, {file = "cryptography-47.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b9a8943e359b7615db1a3ba587994618e094ff3d6fa5a390c73d079ce18b3973"},
{file = "cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308"}, {file = "cryptography-47.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f5c15764f261394b22aef6b00252f5195f46f2ca300bec57149474e2538b31f8"},
{file = "cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77"}, {file = "cryptography-47.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9c59ab0e0fa3a180a5a9c59f3a5abe3ef90d474bc56d7fadfbe80359491b615b"},
{file = "cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1"}, {file = "cryptography-47.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:34b4358b925a5ea3e14384ca781a2c0ef7ac219b57bb9eacc4457078e2b19f92"},
{file = "cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef"}, {file = "cryptography-47.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0024b87d47ae2399165a6bfb20d24888881eeab83ae2566d62467c5ff0030ce7"},
{file = "cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de"}, {file = "cryptography-47.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:1e47422b5557bb82d3fff997e8d92cff4e28b9789576984f08c248d2b3535d93"},
{file = "cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83"}, {file = "cryptography-47.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:6f29f36582e6151d9686235e586dd35bb67491f024767d10b842e520dc6a07ac"},
{file = "cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb"}, {file = "cryptography-47.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a9b761f012a943b7de0e828843c5688d0de94a0578d44d6c85a1bae32f87791f"},
{file = "cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b"}, {file = "cryptography-47.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4e1de79e047e25d6e9f8cea71c86b4a53aced64134f0f003bbcbf3655fd172c8"},
{file = "cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85"}, {file = "cryptography-47.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef6b3634087f18d2155b1e8ce264e5345a753da2c5fa9815e7d41315c90f8318"},
{file = "cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e"}, {file = "cryptography-47.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:11dbb9f50a0f1bb9757b3d8c27c1101780efb8f0bdecfb12439c22a74d64c001"},
{file = "cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457"}, {file = "cryptography-47.0.0-cp311-abi3-win32.whl", hash = "sha256:7fda2f02c9015db3f42bb8a22324a454516ed10a8c29ca6ece6cdbb5efe2a203"},
{file = "cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b"}, {file = "cryptography-47.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:f5c3296dab66202f1b18a91fa266be93d6aa0c2806ea3d67762c69f60adc71aa"},
{file = "cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842"}, {file = "cryptography-47.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:be12cb6a204f77ed968bcefe68086eb061695b540a3dd05edac507a3111b25f0"},
{file = "cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c"}, {file = "cryptography-47.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2ebd84adf0728c039a3be2700289378e1c164afc6748df1a5ed456767bef9ba7"},
{file = "cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902"}, {file = "cryptography-47.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f68d6fbc7fbbcfb0939fea72c3b96a9f9a6edfc0e1b1d29778a2066030418b1"},
{file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d"}, {file = "cryptography-47.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:6651d32eff255423503aa276739da98c30f26c40cbeffcc6048e0d54ef704c0c"},
{file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022"}, {file = "cryptography-47.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3fb8fa48075fad7193f2e5496135c6a76ac4b2aa5a38433df0a539296b377829"},
{file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce"}, {file = "cryptography-47.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11438c7518132d95f354fa01a4aa2f806d172a061a7bed18cf18cbdacdb204d7"},
{file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f"}, {file = "cryptography-47.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8c1a736bbb3288005796c3f7ccb9453360d7fed483b13b9f468aea5171432923"},
{file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99"}, {file = "cryptography-47.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:f1557695e5c2b86e204f6ce9470497848634100787935ab7adc5397c54abd7ab"},
{file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1"}, {file = "cryptography-47.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:f9a034b642b960767fb343766ae5ba6ad653f2e890ddd82955aef288ffea8736"},
{file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2"}, {file = "cryptography-47.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:b1c76fca783aa7698eb21eb14f9c4aa09452248ee54a627d125025a43f83e7a7"},
{file = "cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e"}, {file = "cryptography-47.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4f7722c97826770bab8ae92959a2e7b20a5e9e9bf4deae68fd86c3ca457bab52"},
{file = "cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee"}, {file = "cryptography-47.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:09f6d7bf6724f8db8b32f11eccf23efc8e759924bc5603800335cf8859a3ddbd"},
{file = "cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298"}, {file = "cryptography-47.0.0-cp314-cp314t-win32.whl", hash = "sha256:6eebcaf0df1d21ce1f90605c9b432dd2c4f4ab665ac29a40d5e3fc68f51b5e63"},
{file = "cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb"}, {file = "cryptography-47.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:51c9313e90bd1690ec5a75ed047c27c0b8e6c570029712943d6116ef9a90620b"},
{file = "cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4"}, {file = "cryptography-47.0.0-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:14432c8a9bcb37009784f9594a62fae211a2ae9543e96c92b2a8e4c3cd5cd0c4"},
{file = "cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7"}, {file = "cryptography-47.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:07efe86201817e7d3c18781ca9770bc0db04e1e48c994be384e4602bc38f8f27"},
{file = "cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832"}, {file = "cryptography-47.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b45761c6ec22b7c726d6a829558777e32d0f1c8be7c3f3480f9c912d5ee8a10"},
{file = "cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163"}, {file = "cryptography-47.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:edd4da498015da5b9f26d38d3bfc2e90257bfa9cbed1f6767c282a0025ae649b"},
{file = "cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2"}, {file = "cryptography-47.0.0-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9af828c0d5a65c70ec729cd7495a4bf1a67ecb66417b8f02ff125ab8a6326a74"},
{file = "cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067"}, {file = "cryptography-47.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:256d07c78a04d6b276f5df935a9923275f53bd1522f214447fdf365494e2d515"},
{file = "cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0"}, {file = "cryptography-47.0.0-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:5d0e362ff51041b0c0d219cc7d6924d7b8996f57ce5712bdcef71eb3c65a59cc"},
{file = "cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba"}, {file = "cryptography-47.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:1581aef4219f7ca2849d0250edaa3866212fb74bf5667284f46aa92f9e65c1ca"},
{file = "cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006"}, {file = "cryptography-47.0.0-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a49a3eb5341b9503fa3000a9a0db033161db90d47285291f53c2a9d2cd1b7f76"},
{file = "cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0"}, {file = "cryptography-47.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2207a498b03275d0051589e326b79d4cf59985c99031b05bb292ac52631c37fe"},
{file = "cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85"}, {file = "cryptography-47.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a02675e2fabd0c0fc04c868b8781863cbf1967691543c22f5470500ff840b31"},
{file = "cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e"}, {file = "cryptography-47.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80887c5cbd1774683cb126f0ab4184567f080071d5acf62205acb354b4b753b7"},
{file = "cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246"}, {file = "cryptography-47.0.0-cp38-abi3-win32.whl", hash = "sha256:ed67ea4e0cfb5faa5bc7ecb6e2b8838f3807a03758eec239d6c21c8769355310"},
{file = "cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3"}, {file = "cryptography-47.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:835d2d7f47cdc53b3224e90810fb1d36ca94ea29cc1801fb4c1bc43876735769"},
{file = "cryptography-46.0.7-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc9ab8856ae6cf7c9358430e49b368f3108f050031442eaeb6b9d87e4dcf4e4f"}, {file = "cryptography-47.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f1207974a904e005f762869996cf620e9bf79ecb4622f148550bb48e0eb35a7"},
{file = "cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:d3b99c535a9de0adced13d159c5a9cf65c325601aa30f4be08afd680643e9c15"}, {file = "cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:1a405c08857258c11016777e11c02bacbe7ef596faf259305d282272a3a05cbe"},
{file = "cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:d02c738dacda7dc2a74d1b2b3177042009d5cab7c7079db74afc19e56ca1b455"}, {file = "cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:20fdbe3e38fb67c385d233c89371fa27f9909f6ebca1cecc20c13518dae65475"},
{file = "cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:04959522f938493042d595a736e7dbdff6eb6cc2339c11465b3ff89343b65f65"}, {file = "cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:f7db373287273d8af1414cf95dc4118b13ffdc62be521997b0f2b270771fef50"},
{file = "cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:3986ac1dee6def53797289999eabe84798ad7817f3e97779b5061a95b0ee4968"}, {file = "cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:9fe6b7c64926c765f9dff301f9c1b867febcda5768868ca084e18589113732ab"},
{file = "cryptography-46.0.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:258514877e15963bd43b558917bc9f54cf7cf866c38aa576ebf47a77ddbc43a4"}, {file = "cryptography-47.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cffbba3392df0fa8629bb7f43454ee2925059ee158e23c54620b9063912b86c8"},
{file = "cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5"}, {file = "cryptography-47.0.0.tar.gz", hash = "sha256:9f8e55fe4e63613a5e1cc5819030f27b97742d720203a087802ce4ce9ceb52bb"},
] ]
[package.dependencies] [package.dependencies]
@@ -450,14 +450,7 @@ cffi = {version = ">=2.0.0", markers = "python_full_version >= \"3.9.0\" and pla
typing-extensions = {version = ">=4.13.2", markers = "python_full_version < \"3.11.0\""} typing-extensions = {version = ">=4.13.2", markers = "python_full_version < \"3.11.0\""}
[package.extras] [package.extras]
docs = ["sphinx (>=5.3.0)", "sphinx-inline-tabs", "sphinx-rtd-theme (>=3.0.0)"]
docstest = ["pyenchant (>=3)", "readme-renderer (>=30.0)", "sphinxcontrib-spelling (>=7.3.1)"]
nox = ["nox[uv] (>=2024.4.15)"]
pep8test = ["check-sdist", "click (>=8.0.1)", "mypy (>=1.14)", "ruff (>=0.11.11)"]
sdist = ["build (>=1.0.0)"]
ssh = ["bcrypt (>=3.1.5)"] ssh = ["bcrypt (>=3.1.5)"]
test = ["certifi (>=2024)", "cryptography-vectors (==46.0.7)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"]
test-randomorder = ["pytest-randomly"]
[[package]] [[package]]
name = "exceptiongroup" name = "exceptiongroup"
@@ -465,7 +458,7 @@ version = "1.3.1"
description = "Backport of PEP 654 (exception groups)" description = "Backport of PEP 654 (exception groups)"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
groups = ["main", "dev"] groups = ["main"]
markers = "python_version == \"3.10\"" markers = "python_version == \"3.10\""
files = [ files = [
{file = "exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598"}, {file = "exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598"},
@@ -495,91 +488,91 @@ smmap = ">=3.0.1,<6"
[[package]] [[package]]
name = "gitpython" name = "gitpython"
version = "3.1.46" version = "3.1.47"
description = "GitPython is a Python library used to interact with Git repositories" description = "GitPython is a Python library used to interact with Git repositories"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
groups = ["main"] groups = ["main"]
files = [ files = [
{file = "gitpython-3.1.46-py3-none-any.whl", hash = "sha256:79812ed143d9d25b6d176a10bb511de0f9c67b1fa641d82097b0ab90398a2058"}, {file = "gitpython-3.1.47-py3-none-any.whl", hash = "sha256:489f590edfd6d20571b2c0e72c6a6ac6915ee8b8cd04572330e3842207a78905"},
{file = "gitpython-3.1.46.tar.gz", hash = "sha256:400124c7d0ef4ea03f7310ac2fbf7151e09ff97f2a3288d64a440c584a29c37f"}, {file = "gitpython-3.1.47.tar.gz", hash = "sha256:dba27f922bd2b42cb54c87a8ab3cb6beb6bf07f3d564e21ac848913a05a8a3cd"},
] ]
[package.dependencies] [package.dependencies]
gitdb = ">=4.0.1,<5" gitdb = ">=4.0.1,<5"
[package.extras] [package.extras]
doc = ["sphinx (>=7.1.2,<7.2)", "sphinx-autodoc-typehints", "sphinx_rtd_theme"] doc = ["sphinx (>=7.4.7,<8)", "sphinx-autodoc-typehints", "sphinx_rtd_theme"]
test = ["coverage[toml]", "ddt (>=1.1.1,!=1.4.3)", "mock ; python_version < \"3.8\"", "mypy (==1.18.2) ; python_version >= \"3.9\"", "pre-commit", "pytest (>=7.3.1)", "pytest-cov", "pytest-instafail", "pytest-mock", "pytest-sugar", "typing-extensions ; python_version < \"3.11\""] test = ["coverage[toml]", "ddt (>=1.1.1,!=1.4.3)", "mock ; python_version < \"3.8\"", "mypy (==1.18.2) ; python_version >= \"3.9\"", "pre-commit", "pytest (>=7.3.1)", "pytest-cov", "pytest-instafail", "pytest-mock", "pytest-sugar", "typing-extensions ; python_version < \"3.11\""]
[[package]] [[package]]
name = "greenlet" name = "greenlet"
version = "3.4.0" version = "3.5.0"
description = "Lightweight in-process concurrent programming" description = "Lightweight in-process concurrent programming"
optional = false optional = false
python-versions = ">=3.10" python-versions = ">=3.10"
groups = ["main"] groups = ["main"]
markers = "platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\"" markers = "platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\""
files = [ files = [
{file = "greenlet-3.4.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:d18eae9a7fb0f499efcd146b8c9750a2e1f6e0e93b5a382b3481875354a430e6"}, {file = "greenlet-3.5.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:29ea813b2e1f45fa9649a17853b2b5465c4072fbcb072e5af6cd3a288216574a"},
{file = "greenlet-3.4.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:636d2f95c309e35f650e421c23297d5011716be15d966e6328b367c9fc513a82"}, {file = "greenlet-3.5.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:804a70b328e706b785c6ef16187051c394a63dd1a906d89be24b6ad77759f13f"},
{file = "greenlet-3.4.0-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:234582c20af9742583c3b2ddfbdbb58a756cfff803763ffaae1ac7990a9fac31"}, {file = "greenlet-3.5.0-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:884f649de075b84739713d41dd4dfd41e2b910bfb769c4a3ea02ec1da52cd9bb"},
{file = "greenlet-3.4.0-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ac6a5f618be581e1e0713aecec8e54093c235e5fa17d6d8eb7ffc487e2300508"}, {file = "greenlet-3.5.0-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4d0eadc7e4d9ffb2af4247b606cae307be8e448911e5a0d0b16d72fc3d224cfd"},
{file = "greenlet-3.4.0-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:523677e69cd4711b5a014e37bc1fb3a29947c3e3a5bb6a527e1cc50312e5a398"}, {file = "greenlet-3.5.0-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b28037cb07768933c54d81bfe47a85f9f402f57d7d69743b991a713b63954eb"},
{file = "greenlet-3.4.0-cp310-cp310-manylinux_2_39_riscv64.whl", hash = "sha256:d336d46878e486de7d9458653c722875547ac8d36a1cff9ffaf4a74a3c1f62eb"}, {file = "greenlet-3.5.0-cp310-cp310-manylinux_2_39_riscv64.whl", hash = "sha256:f8c30c2225f40dd76c50790f0eb3b5c7c18431efb299e2782083e1981feed243"},
{file = "greenlet-3.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b45e45fe47a19051a396abb22e19e7836a59ee6c5a90f3be427343c37908d65b"}, {file = "greenlet-3.5.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cda05425526240807408156b6960a17a79a0c760b813573b67027823be760977"},
{file = "greenlet-3.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5434271357be07f3ad0936c312645853b7e689e679e29310e2de09a9ea6c3adf"}, {file = "greenlet-3.5.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9c615f869163e14bb1ced20322d8038fb680b08236521ac3f30cd4c1288785a0"},
{file = "greenlet-3.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:a19093fbad824ed7c0f355b5ff4214bffda5f1a7f35f29b31fcaa240cc0135ab"}, {file = "greenlet-3.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:ba8f0bdc2fae6ce915dfd0c16d2d00bca7e4247c1eae4416e06430e522137858"},
{file = "greenlet-3.4.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:805bebb4945094acbab757d34d6e1098be6de8966009ab9ca54f06ff492def58"}, {file = "greenlet-3.5.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:8f1cc966c126639cd152fdaa52624d2655f492faa79e013fea161de3e6dda082"},
{file = "greenlet-3.4.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:439fc2f12b9b512d9dfa681c5afe5f6b3232c708d13e6f02c845e0d9f4c2d8c6"}, {file = "greenlet-3.5.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:362624e6a8e5bca3b8233e45eef33903a100e9539a2b995c364d595dbc4018b3"},
{file = "greenlet-3.4.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a70ed1cb0295bee1df57b63bf7f46b4e56a5c93709eea769c1fec1bb23a95875"}, {file = "greenlet-3.5.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5ecd83806b0f4c2f53b1018e0005cd82269ea01d42befc0368730028d850ed1c"},
{file = "greenlet-3.4.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8c5696c42e6bb5cfb7c6ff4453789081c66b9b91f061e5e9367fa15792644e76"}, {file = "greenlet-3.5.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa94cb2288681e3a11645958f1871d48ee9211bd2f66628fdace505927d6e564"},
{file = "greenlet-3.4.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c660bce1940a1acae5f51f0a064f1bc785d07ea16efcb4bc708090afc4d69e83"}, {file = "greenlet-3.5.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ff251e9a0279522e62f6176412869395a64ddf2b5c5f782ff609a8216a4e662"},
{file = "greenlet-3.4.0-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:89995ce5ddcd2896d89615116dd39b9703bfa0c07b583b85b89bf1b5d6eddf81"}, {file = "greenlet-3.5.0-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:64d6ac45f7271f48e45f67c95b54ef73534c52ec041fcda8edf520c6d811f4bc"},
{file = "greenlet-3.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ee407d4d1ca9dc632265aee1c8732c4a2d60adff848057cdebfe5fe94eb2c8a2"}, {file = "greenlet-3.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d874e79afd41a96e11ff4c5d0bc90a80973e476fda1c2c64985667397df432b"},
{file = "greenlet-3.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:956215d5e355fffa7c021d168728321fd4d31fd730ac609b1653b450f6a4bc71"}, {file = "greenlet-3.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0ed006e4b86c59de7467eb2601cd1b77b5a7d657d1ee55e30fe30d76451edba4"},
{file = "greenlet-3.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:5cb614ace7c27571270354e9c9f696554d073f8aa9319079dcba466bbdead711"}, {file = "greenlet-3.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:703cb211b820dbffbbc55a16bfc6e4583a6e6e990f33a119d2cc8b83211119c8"},
{file = "greenlet-3.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:04403ac74fe295a361f650818de93be11b5038a78f49ccfb64d3b1be8fbf1267"}, {file = "greenlet-3.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:6c18dfb59c70f5a94acd271c72e90128c3c776e41e5f07767908c8c1b74ad339"},
{file = "greenlet-3.4.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:1a54a921561dd9518d31d2d3db4d7f80e589083063ab4d3e2e950756ef809e1a"}, {file = "greenlet-3.5.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:db2910d3c809444e0a20147361f343fe2798e106af8d9d8506f5305302655a9f"},
{file = "greenlet-3.4.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16dec271460a9a2b154e3b1c2fa1050ce6280878430320e85e08c166772e3f97"}, {file = "greenlet-3.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ec9ea74e7268ace7f9aab1b1a4e730193fc661b39a993cd91c606c32d4a3628"},
{file = "greenlet-3.4.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:90036ce224ed6fe75508c1907a77e4540176dcf0744473627785dd519c6f9996"}, {file = "greenlet-3.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54d243512da35485fc7a6bf3c178fdda6327a9d6506fcdd62b1abd1e41b2927b"},
{file = "greenlet-3.4.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6f0def07ec9a71d72315cf26c061aceee53b306c36ed38c35caba952ea1b319d"}, {file = "greenlet-3.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:41353ec2ecedf7aa8f682753a41919f8718031a6edac46b8d3dc7ed9e1ceb136"},
{file = "greenlet-3.4.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a1c4f6b453006efb8310affb2d132832e9bbb4fc01ce6df6b70d810d38f1f6dc"}, {file = "greenlet-3.5.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d280a7f5c331622c69f97eb167f33577ff2d1df282c41cd15907fc0a3ca198c"},
{file = "greenlet-3.4.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:0e1254cf0cbaa17b04320c3a78575f29f3c161ef38f59c977108f19ffddaf077"}, {file = "greenlet-3.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:58c1c374fe2b3d852f9b6b11a7dff4c85404e51b9a596fd9e89cf904eb09866d"},
{file = "greenlet-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9b2d9a138ffa0e306d0e2b72976d2fb10b97e690d40ab36a472acaab0838e2de"}, {file = "greenlet-3.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1eb67d5adefb5bd2e182d42678a328979a209e4e82eb93575708185d31d1f588"},
{file = "greenlet-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8424683caf46eb0eb6f626cb95e008e8cc30d0cb675bdfa48200925c79b38a08"}, {file = "greenlet-3.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2628d6c86f6cb0cb45e0c3c54058bbec559f57eaae699447748cb3928150577e"},
{file = "greenlet-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:a0a53fb071531d003b075c444014ff8f8b1a9898d36bb88abd9ac7b3524648a2"}, {file = "greenlet-3.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:d4d9f0624c775f2dfc56ba54d515a8c771044346852a918b405914f6b19d7fd8"},
{file = "greenlet-3.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:f38b81880ba28f232f1f675893a39cf7b6db25b31cc0a09bb50787ecf957e85e"}, {file = "greenlet-3.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:83ed9f27f1680b50e89f40f6df348a290ea234b249a4003d366663a12eab94f2"},
{file = "greenlet-3.4.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:43748988b097f9c6f09364f260741aa73c80747f63389824435c7a50bfdfd5c1"}, {file = "greenlet-3.5.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:5a5ed18de6a0f6cc7087f1563f6bd93fc7df1c19165ca01e9bde5a5dc281d106"},
{file = "greenlet-3.4.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5566e4e2cd7a880e8c27618e3eab20f3494452d12fd5129edef7b2f7aa9a36d1"}, {file = "greenlet-3.5.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a717fbc46d8a354fa675f7c1e813485b6ba3885f9bef0cd56e5ba27d758ff5b"},
{file = "greenlet-3.4.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1054c5a3c78e2ab599d452f23f7adafef55062a783a8e241d24f3b633ba6ff82"}, {file = "greenlet-3.5.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ddc090c5c1792b10246a78e8c2163ebbe04cf877f9d785c230a7b27b39ad038e"},
{file = "greenlet-3.4.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:98eedd1803353daf1cd9ef23eef23eda5a4d22f99b1f998d273a8b78b70dd47f"}, {file = "greenlet-3.5.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4964101b8585c144cbda5532b1aa644255126c08a265dae90c16e7a0e63aaa9d"},
{file = "greenlet-3.4.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f82cb6cddc27dd81c96b1506f4aa7def15070c3b2a67d4e46fd19016aacce6cf"}, {file = "greenlet-3.5.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2094acd54b272cb6eae8c03dd87b3fa1820a4cef18d6889c378d503500a1dc13"},
{file = "greenlet-3.4.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:b7857e2202aae67bc5725e0c1f6403c20a8ff46094ece015e7d474f5f7020b55"}, {file = "greenlet-3.5.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:7022615368890680e67b9965d33f5773aade330d5343bbe25560135aaa849eae"},
{file = "greenlet-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:227a46251ecba4ff46ae742bc5ce95c91d5aceb4b02f885487aff269c127a729"}, {file = "greenlet-3.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5e05ba267789ea87b5a155cf0e810b1ab88bf18e9e8740813945ceb8ee4350ba"},
{file = "greenlet-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5b99e87be7eba788dd5b75ba1cde5639edffdec5f91fe0d734a249535ec3408c"}, {file = "greenlet-3.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0ecec963079cd58cbd14723582384f11f166fd58883c15dcbfb342e0bc9b5846"},
{file = "greenlet-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:849f8bc17acd6295fcb5de8e46d55cc0e52381c56eaf50a2afd258e97bc65940"}, {file = "greenlet-3.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:728d9667d8f2f586644b748dbd9bb67e50d6a9381767d1357714ea6825bb3bf5"},
{file = "greenlet-3.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:9390ad88b652b1903814eaabd629ca184db15e0eeb6fe8a390bbf8b9106ae15a"}, {file = "greenlet-3.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:47422135b1d308c14b2c6e758beedb1acd33bb91679f5670edf77bf46244722b"},
{file = "greenlet-3.4.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:10a07aca6babdd18c16a3f4f8880acfffc2b88dfe431ad6aa5f5740759d7d75e"}, {file = "greenlet-3.5.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:f35807464c4c58c55f0d31dfa83c541a5615d825c2fe3d2b95360cf7c4e3c0a8"},
{file = "greenlet-3.4.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:076e21040b3a917d3ce4ad68fb5c3c6b32f1405616c4a57aa83120979649bd3d"}, {file = "greenlet-3.5.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55fa7ea52771be44af0de27d8b80c02cd18c2c3cddde6c847ecebdf72418b6a1"},
{file = "greenlet-3.4.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e82689eea4a237e530bb5cb41b180ef81fa2160e1f89422a67be7d90da67f615"}, {file = "greenlet-3.5.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a97e4821aa710603f94de0da25f25096454d78ffdace5dc77f3a006bc01abba3"},
{file = "greenlet-3.4.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:06c2d3b89e0c62ba50bd7adf491b14f39da9e7e701647cb7b9ff4c99bee04b19"}, {file = "greenlet-3.5.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bf2d8a80bec89ab46221ae45c5373d5ba0bd36c19aa8508e85c6cd7e5106cd37"},
{file = "greenlet-3.4.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4df3b0b2289ec686d3c821a5fee44259c05cfe824dd5e6e12c8e5f5df23085cf"}, {file = "greenlet-3.5.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f52a464e4ed91780bdfbbdd2b97197f3accaa629b98c200f4dffada759f3ae7"},
{file = "greenlet-3.4.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:070b8bac2ff3b4d9e0ff36a0d19e42103331d9737e8504747cd1e659f76297bd"}, {file = "greenlet-3.5.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:1bae92a1dd94c5f9d9493c3a212dd874c202442047cf96446412c862feca83a2"},
{file = "greenlet-3.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8bff29d586ea415688f4cec96a591fcc3bf762d046a796cdadc1fdb6e7f2d5bf"}, {file = "greenlet-3.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:762612baf1161ccb8437c0161c668a688223cba28e1bf038f4eb47b13e39ccdf"},
{file = "greenlet-3.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8a569c2fb840c53c13a2b8967c63621fafbd1a0e015b9c82f408c33d626a2fda"}, {file = "greenlet-3.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:57a43c6079a89713522bc4bcb9f75070ecf5d3dbad7792bfe42239362cbf2a16"},
{file = "greenlet-3.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:207ba5b97ea8b0b60eb43ffcacf26969dd83726095161d676aac03ff913ee50d"}, {file = "greenlet-3.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:3bc59be3945ae9750b9e7d45067d01ae3fe90ea5f9ade99239dabdd6e28a5033"},
{file = "greenlet-3.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:f8296d4e2b92af34ebde81085a01690f26a51eb9ac09a0fcadb331eb36dbc802"}, {file = "greenlet-3.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:a96fcee45e03fe30a62669fd16ab5c9d3c172660d3085605cb1e2d1280d3c988"},
{file = "greenlet-3.4.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d70012e51df2dbbccfaf63a40aaf9b40c8bed37c3e3a38751c926301ce538ece"}, {file = "greenlet-3.5.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:a10a732421ab4fec934783ce3e54763470d0181db6e3468f9103a275c3ed1853"},
{file = "greenlet-3.4.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a58bec0751f43068cd40cff31bb3ca02ad6000b3a51ca81367af4eb5abc480c8"}, {file = "greenlet-3.5.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fc391b1566f2907d17aaebe78f8855dc45675159a775fcf9e61f8ee0078e87f"},
{file = "greenlet-3.4.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05fa0803561028f4b2e3b490ee41216a842eaee11aed004cc343a996d9523aa2"}, {file = "greenlet-3.5.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:680bd0e7ad5e8daa8a4aa89f68fd6adc834b8a8036dc256533f7e08f4a4b01f7"},
{file = "greenlet-3.4.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c4cd56a9eb7a6444edbc19062f7b6fbc8f287c663b946e3171d899693b1c19fa"}, {file = "greenlet-3.5.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1aa4ce8debcd4ea7fb2e150f3036588c41493d1d52c43538924ae1819003f4ce"},
{file = "greenlet-3.4.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e60d38719cb80b3ab5e85f9f1aed4960acfde09868af6762ccb27b260d68f4ed"}, {file = "greenlet-3.5.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ddb36c7d6c9c0a65f18c7258634e0c416c6ab59caac8c987b96f80c2ebda0112"},
{file = "greenlet-3.4.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:1f85f204c4d54134ae850d401fa435c89cd667d5ce9dc567571776b45941af72"}, {file = "greenlet-3.5.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:728a73687e39ae9ca34e4694cbf2f049d3fbc7174639468d0f67200a97d8f9e2"},
{file = "greenlet-3.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f50c804733b43eded05ae694691c9aa68bca7d0a867d67d4a3f514742a2d53f"}, {file = "greenlet-3.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e5ddf316ced87539144621453c3aef229575825fe60c604e62bedc4003f372b2"},
{file = "greenlet-3.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2d4f0635dc4aa638cda4b2f5a07ae9a2cff9280327b581a3fcb6f317b4fbc38a"}, {file = "greenlet-3.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4a448128607be0de65342dc9b31be7f948ef4cc0bc8832069350abefd310a8f2"},
{file = "greenlet-3.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1a4a48f24681300c640f143ba7c404270e1ebbbcf34331d7104a4ff40f8ea705"}, {file = "greenlet-3.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d60097128cb0a1cab9ea541186ea13cd7b847b8449a7787c2e2350da0cb82d86"},
{file = "greenlet-3.4.0.tar.gz", hash = "sha256:f50a96b64dafd6169e595a5c56c9146ef80333e67d4476a65a9c55f400fc22ff"}, {file = "greenlet-3.5.0.tar.gz", hash = "sha256:d419647372241bc68e957bf38d5c1f98852155e4146bd1e4121adea81f4f01e4"},
] ]
[package.extras] [package.extras]
@@ -588,14 +581,14 @@ test = ["objgraph", "psutil", "setuptools"]
[[package]] [[package]]
name = "idna" name = "idna"
version = "3.12" version = "3.13"
description = "Internationalized Domain Names in Applications (IDNA)" description = "Internationalized Domain Names in Applications (IDNA)"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["main"] groups = ["main"]
files = [ files = [
{file = "idna-3.12-py3-none-any.whl", hash = "sha256:60ffaa1858fac94c9c124728c24fcde8160f3fb4a7f79aa8cdd33a9d1af60a67"}, {file = "idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3"},
{file = "idna-3.12.tar.gz", hash = "sha256:724e9952cc9e2bd7550ea784adb098d837ab5267ef67a1ab9cf7846bdbdd8254"}, {file = "idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242"},
] ]
[package.extras] [package.extras]
@@ -607,7 +600,7 @@ version = "2.3.0"
description = "brain-dead simple config-ini parsing" description = "brain-dead simple config-ini parsing"
optional = false optional = false
python-versions = ">=3.10" python-versions = ">=3.10"
groups = ["main", "dev"] groups = ["main"]
files = [ files = [
{file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"},
{file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"},
@@ -1012,14 +1005,14 @@ files = [
[[package]] [[package]]
name = "packaging" name = "packaging"
version = "26.1" version = "26.2"
description = "Core utilities for Python packages" description = "Core utilities for Python packages"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["main", "dev"] groups = ["main"]
files = [ files = [
{file = "packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f"}, {file = "packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e"},
{file = "packaging-26.1.tar.gz", hash = "sha256:f042152b681c4bfac5cae2742a55e103d27ab2ec0f3d88037136b6bfe7c9c5de"}, {file = "packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661"},
] ]
[[package]] [[package]]
@@ -1327,7 +1320,7 @@ version = "1.6.0"
description = "plugin and hook calling mechanisms for python" description = "plugin and hook calling mechanisms for python"
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["main", "dev"] groups = ["main"]
files = [ files = [
{file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"},
{file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"},
@@ -1454,7 +1447,7 @@ version = "2.20.0"
description = "Pygments is a syntax highlighting package written in Python." description = "Pygments is a syntax highlighting package written in Python."
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["main", "dev"] groups = ["main"]
files = [ files = [
{file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"}, {file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"},
{file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"}, {file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"},
@@ -1469,7 +1462,7 @@ version = "9.0.3"
description = "pytest: simple powerful testing with Python" description = "pytest: simple powerful testing with Python"
optional = false optional = false
python-versions = ">=3.10" python-versions = ">=3.10"
groups = ["main", "dev"] groups = ["main"]
files = [ files = [
{file = "pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9"}, {file = "pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9"},
{file = "pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c"}, {file = "pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c"},
@@ -2106,7 +2099,7 @@ version = "2.4.1"
description = "A lil' TOML parser" description = "A lil' TOML parser"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["main", "dev"] groups = ["main"]
markers = "python_version == \"3.10\"" markers = "python_version == \"3.10\""
files = [ files = [
{file = "tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30"}, {file = "tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30"},
@@ -2192,20 +2185,20 @@ files = [
[[package]] [[package]]
name = "typer" name = "typer"
version = "0.24.1" version = "0.25.0"
description = "Typer, build great CLIs. Easy to code. Based on Python type hints." description = "Typer, build great CLIs. Easy to code. Based on Python type hints."
optional = false optional = false
python-versions = ">=3.10" python-versions = ">=3.10"
groups = ["main"] groups = ["main"]
files = [ files = [
{file = "typer-0.24.1-py3-none-any.whl", hash = "sha256:112c1f0ce578bfb4cab9ffdabc68f031416ebcc216536611ba21f04e9aa84c9e"}, {file = "typer-0.25.0-py3-none-any.whl", hash = "sha256:ac01b48823d3db9a83c9e164338057eadbb1c9957a2a6b4eeb486669c560b5dc"},
{file = "typer-0.24.1.tar.gz", hash = "sha256:e39b4732d65fbdcde189ae76cf7cd48aeae72919dea1fdfc16593be016256b45"}, {file = "typer-0.25.0.tar.gz", hash = "sha256:123eaf9f19bb40fd268310e12a542c0c6b4fab9c98d9d23342a01ff95e3ce930"},
] ]
[package.dependencies] [package.dependencies]
annotated-doc = ">=0.0.2" annotated-doc = ">=0.0.2"
click = ">=8.2.1" click = ">=8.2.1"
rich = ">=12.3.0" rich = ">=13.8.0"
shellingham = ">=1.3.0" shellingham = ">=1.3.0"
[[package]] [[package]]
@@ -2214,24 +2207,23 @@ version = "4.15.0"
description = "Backported and Experimental Type Hints for Python 3.9+" description = "Backported and Experimental Type Hints for Python 3.9+"
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["main", "dev"] groups = ["main"]
files = [ files = [
{file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"},
{file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"},
] ]
markers = {dev = "python_version == \"3.10\""}
[[package]] [[package]]
name = "tzdata" name = "tzdata"
version = "2026.1" version = "2026.2"
description = "Provider of IANA time zone data" description = "Provider of IANA time zone data"
optional = false optional = false
python-versions = ">=2" python-versions = ">=2"
groups = ["main"] groups = ["main"]
markers = "sys_platform == \"win32\" or sys_platform == \"emscripten\" or python_version == \"3.10\"" markers = "sys_platform == \"win32\" or sys_platform == \"emscripten\" or python_version == \"3.10\""
files = [ files = [
{file = "tzdata-2026.1-py2.py3-none-any.whl", hash = "sha256:4b1d2be7ac37ceafd7327b961aa3a54e467efbdb563a23655fbfe0d39cfc42a9"}, {file = "tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7"},
{file = "tzdata-2026.1.tar.gz", hash = "sha256:67658a1903c75917309e753fdc349ac0efd8c27db7a0cb406a25be4840f87f98"}, {file = "tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10"},
] ]
[[package]] [[package]]
@@ -2299,4 +2291,4 @@ watchmedo = ["PyYAML (>=3.10)"]
[metadata] [metadata]
lock-version = "2.1" lock-version = "2.1"
python-versions = ">=3.10,<4" python-versions = ">=3.10,<4"
content-hash = "8c1d6c7a42479b66456c88557996117bcc271796cc95346a1d64d59953757e0c" content-hash = "6c56d9f68b799c005b4591f3ff558c3a90138546f9ca3f946e3ed52d55a56fe2"

3
poetry.toml Normal file
View File

@@ -0,0 +1,3 @@
[virtualenvs]
in-project = true
path = ".venv"

View File

@@ -14,14 +14,13 @@ dependencies = [
"pytest-env (>=1.6.0,<2.0.0)" "pytest-env (>=1.6.0,<2.0.0)"
] ]
[tool.poetry]
package-mode = false
[build-system] [build-system]
requires = ["poetry-core>=1.0.0"] requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api" build-backend = "poetry.core.masonry.api"
[virtualenvs]
in-project = true
create = true
[tool.alembic] [tool.alembic]
script_location = "%(here)s/migrations" script_location = "%(here)s/migrations"
truncate_slug_length = 10 truncate_slug_length = 10
@@ -30,15 +29,6 @@ prepend_sys_path = [
"." "."
] ]
[tool.poetry]
package-mode = false
[tool.poetry.group.dev.dependencies]
pytest = ">=9.0"
[tool.poetry.dependencies]
python = ">=3.10,<4"
[tool.pytest.ini_options] [tool.pytest.ini_options]
log_cli = true log_cli = true
log_cli_level = "WARNING" log_cli_level = "WARNING"

View File

@@ -5,6 +5,7 @@ import pytest
from alembic import config from alembic import config
from pytest_alembic.config import Config from pytest_alembic.config import Config
from streamlit.testing.v1 import AppTest from streamlit.testing.v1 import AppTest
import streamlit as st
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -18,11 +19,21 @@ def setup_database(alembic_runner):
@pytest.fixture @pytest.fixture
def alembic_config() -> Config: def alembic_config() -> Config:
logging.info("Setting up alembic config") logger.info("Setting up alembic config")
alembic_cfg = config.Config(toml_file="pyproject.toml") alembic_cfg = config.Config(toml_file="pyproject.toml")
alembic_cfg.set_main_option("sqlalchemy.url", os.getenv("DATABASE_URL", "")) alembic_cfg.set_main_option("sqlalchemy.url", os.getenv("DATABASE_URL", ""))
return Config(alembic_config=alembic_cfg) return Config(alembic_config=alembic_cfg)
@pytest.fixture(autouse=True)
def user_config():
logger.info("Setting up test user")
st.user = None
st.session_state.user_id = 1
st.session_state.user_name = "Test User"
st.session_state.user_email = "test@test.local"
st.session_state.user_external_id = "111-2222-3333"
st.session_state.current_theme = "light"
@pytest.fixture @pytest.fixture
def app() -> AppTest: def app() -> AppTest:
return AppTest.from_file("app/streamlit_app.py") return AppTest.from_file("app/streamlit_app.py")

View File

@@ -14,6 +14,7 @@ def test_create_counter():
assert counters["type"][0] == CounterType.SIMPLE assert counters["type"][0] == CounterType.SIMPLE
assert counters["color"][0] == '020122' assert counters["color"][0] == '020122'
def test_remove_counter(): def test_remove_counter():
crud.create_counter("Test", CounterType.SIMPLE, "020122") crud.create_counter("Test", CounterType.SIMPLE, "020122")
assert len(crud.get_counters()) == 1 assert len(crud.get_counters()) == 1
@@ -37,3 +38,22 @@ def test_increment_counter():
assert daily_stats["count"][0] == 2 assert daily_stats["count"][0] == 2
def test_get_color_palettes():
palettes = crud.get_color_palettes()
assert len(palettes) == 5
assert palettes['name'][0] == "Flames"
def test_get_user_colors():
palettes = crud.get_color_palettes()
palette_id = crud.get_color_palette()
assert palette_id == 1
assert palettes.loc[palettes['id'] == palette_id]["color1"][0] == 'F2F3AE'
crud.set_color_palette(2)
palettes = crud.get_color_palettes()
palette_id = crud.get_color_palette()
assert palette_id == 2
assert palettes.loc[palettes['id'] == palette_id]["color1"][1] == '2B4141'

View File

@@ -15,19 +15,19 @@ def test_all_daily_stats():
crud.create_counter("Test2", CounterType.SIMPLE, "020122") crud.create_counter("Test2", CounterType.SIMPLE, "020122")
with connection().session as session: with connection().session as session:
query = text(""" query = text("""
INSERT INTO entries (counter_id, "timestamp", increment) INSERT INTO entries (counter_id, user_id, "timestamp", increment)
VALUES VALUES
(1, date(), 1), (1, 1, date('2026-06-15'), 1),
(1, date(date(), '-1 days'), 2), (1, 1, date(date('2026-06-15'), '-1 days'), 2),
(1, date(date(), '-3 days'), 3), (1, 1, date(date('2026-06-15'), '-3 days'), 3),
(2, date(), 2), (2, 1, date('2026-06-15'), 2),
(2, date(date(), '-1 days'), 4), (2, 1, date(date('2026-06-15'), '-1 days'), 4),
(2, date(date(), '-3 days'), 6) (2, 1, date(date('2026-06-15'), '-3 days'), 6)
""") """)
session.execute(query) session.execute(query)
session.commit() session.commit()
stats = daily_stats.get_all_daily_analytics() stats = daily_stats.get_all_daily_analytics('2026-06-15')
assert json.loads(stats[::-1]["counters"].iloc[0])["Test"] == 1 assert json.loads(stats[::-1]["counters"].iloc[0])["Test"] == 1
assert json.loads(stats[::-1]["counters"].iloc[0])["Test2"] == 2 assert json.loads(stats[::-1]["counters"].iloc[0])["Test2"] == 2
assert json.loads(stats[::-1]["counters"].iloc[1])["Test"] == 2 assert json.loads(stats[::-1]["counters"].iloc[1])["Test"] == 2
@@ -41,16 +41,16 @@ def test_daily_stats():
crud.create_counter("Test", CounterType.SIMPLE, "020122") crud.create_counter("Test", CounterType.SIMPLE, "020122")
with connection().session as session: with connection().session as session:
query = text(""" query = text("""
INSERT INTO entries (counter_id, "timestamp", increment) INSERT INTO entries (counter_id, user_id, "timestamp", increment)
VALUES VALUES
(1, date(), 1), (1, 1, date('2026-06-15'), 1),
(1, date(date(), '-1 days'), 2), (1, 1, date(date('2026-06-15'), '-1 days'), 2),
(1, date(date(), '-3 days'), 3) (1, 1, date(date('2026-06-15'), '-3 days'), 3)
""") """)
session.execute(query) session.execute(query)
session.commit() session.commit()
stats = daily_stats.get_daily_analytics(1) stats = daily_stats.get_daily_analytics(1, '2026-06-15')
assert stats["count"][0] == 1 assert stats["count"][0] == 1
assert stats["count"][1] == 2 assert stats["count"][1] == 2
assert stats["count"][2] == 0 assert stats["count"][2] == 0
@@ -62,19 +62,19 @@ def test_all_monthly_stats():
crud.create_counter("Test2", CounterType.SIMPLE, "020122") crud.create_counter("Test2", CounterType.SIMPLE, "020122")
with connection().session as session: with connection().session as session:
query = text(""" query = text("""
INSERT INTO entries (counter_id, "timestamp", increment) INSERT INTO entries (counter_id, user_id, "timestamp", increment)
VALUES VALUES
(1, date(), 1), (1, 1, date('2026-06-15'), 1),
(1, date(date(), '-1 month'), 2), (1, 1, date(date('2026-06-15'), 'start of month', '-1 month'), 2),
(1, date(date(), '-3 months'), 3), (1, 1, date(date('2026-06-15'), 'start of month', '-3 months'), 3),
(2, date(), 2), (2, 1, date('2026-06-15'), 2),
(2, date(date(), '-2 months'), 4), (2, 1, date(date('2026-06-15'), 'start of month', '-2 months'), 4),
(2, date(date(), '-3 months'), 6) (2, 1, date(date('2026-06-15'), 'start of month', '-3 months'), 6)
""") """)
session.execute(query) session.execute(query)
session.commit() session.commit()
stats = monthly_stats.get_all_monthly_analytics() stats = monthly_stats.get_all_monthly_analytics('2026-06-15')
assert json.loads(stats[::-1]["counters"].iloc[0])["Test"] == 1 assert json.loads(stats[::-1]["counters"].iloc[0])["Test"] == 1
assert json.loads(stats[::-1]["counters"].iloc[0])["Test2"] == 2 assert json.loads(stats[::-1]["counters"].iloc[0])["Test2"] == 2
assert json.loads(stats[::-1]["counters"].iloc[1])["Test"] == 2 assert json.loads(stats[::-1]["counters"].iloc[1])["Test"] == 2
@@ -87,16 +87,16 @@ def test_monthly_stats():
crud.create_counter("Test", CounterType.SIMPLE, "020122") crud.create_counter("Test", CounterType.SIMPLE, "020122")
with connection().session as session: with connection().session as session:
query = text(""" query = text("""
INSERT INTO entries (counter_id, "timestamp", increment) INSERT INTO entries (counter_id, user_id, "timestamp", increment)
VALUES VALUES
(1, date(), 1), (1, 1, date('2026-06-15'), 1),
(1, date(date(), '-1 months'), 2), (1, 1, date(date('2026-06-15'), '-1 months'), 2),
(1, date(date(), '-3 months'), 3) (1, 1, date(date('2026-06-15'), '-3 months'), 3)
""") """)
session.execute(query) session.execute(query)
session.commit() session.commit()
stats = monthly_stats.get_monthly_analytics(1) stats = monthly_stats.get_monthly_analytics(1, '2026-06-15')
assert stats[::-1]["count"].iloc[0] == 1 assert stats[::-1]["count"].iloc[0] == 1
assert stats[::-1]["count"].iloc[1] == 2 assert stats[::-1]["count"].iloc[1] == 2
assert stats[::-1]["count"].iloc[2] == 0 assert stats[::-1]["count"].iloc[2] == 0
@@ -108,19 +108,19 @@ def test_all_yearly_stats():
crud.create_counter("Test2", CounterType.SIMPLE, "020122") crud.create_counter("Test2", CounterType.SIMPLE, "020122")
with connection().session as session: with connection().session as session:
query = text(""" query = text("""
INSERT INTO entries (counter_id, "timestamp", increment) INSERT INTO entries (counter_id, user_id, "timestamp", increment)
VALUES VALUES
(1, date(), 1), (1, 1, date('2026-06-15'), 1),
(1, date(date(), '-1 year'), 2), (1, 1, date(date('2026-06-15'), '-1 year'), 2),
(1, date(date(), '-3 years'), 3), (1, 1, date(date('2026-06-15'), '-3 years'), 3),
(2, date(), 2), (2, 1, date('2026-06-15'), 2),
(2, date(date(), '-2 years'), 4), (2, 1, date(date('2026-06-15'), '-2 years'), 4),
(2, date(date(), '-3 years'), 6) (2, 1, date(date('2026-06-15'), '-3 years'), 6)
""") """)
session.execute(query) session.execute(query)
session.commit() session.commit()
stats = yearly_stats.get_all_yearly_analytics() stats = yearly_stats.get_all_yearly_analytics('2026-06-15')
assert json.loads(stats[::-1]["counters"].iloc[0])["Test"] == 1 assert json.loads(stats[::-1]["counters"].iloc[0])["Test"] == 1
assert json.loads(stats[::-1]["counters"].iloc[0])["Test2"] == 2 assert json.loads(stats[::-1]["counters"].iloc[0])["Test2"] == 2
assert json.loads(stats[::-1]["counters"].iloc[1])["Test"] == 2 assert json.loads(stats[::-1]["counters"].iloc[1])["Test"] == 2
@@ -133,16 +133,16 @@ def test_yearly_stats():
crud.create_counter("Test", CounterType.SIMPLE, "020122") crud.create_counter("Test", CounterType.SIMPLE, "020122")
with connection().session as session: with connection().session as session:
query = text(""" query = text("""
INSERT INTO entries (counter_id, "timestamp", increment) INSERT INTO entries (counter_id, user_id, "timestamp", increment)
VALUES VALUES
(1, date(), 1), (1, 1, date('2026-06-15'), 1),
(1, date(date(), '-1 years'), 2), (1, 1, date(date('2026-06-15'), '-1 years'), 2),
(1, date(date(), '-3 years'), 3) (1, 1, date(date('2026-06-15'), '-3 years'), 3)
""") """)
session.execute(query) session.execute(query)
session.commit() session.commit()
stats = yearly_stats.get_yearly_analytics(1) stats = yearly_stats.get_yearly_analytics(1, '2026-06-15')
assert stats[::-1]["count"].iloc[0] == 1 assert stats[::-1]["count"].iloc[0] == 1
assert stats[::-1]["count"].iloc[1] == 2 assert stats[::-1]["count"].iloc[1] == 2
assert stats[::-1]["count"].iloc[2] == 0 assert stats[::-1]["count"].iloc[2] == 0
@@ -154,19 +154,19 @@ def test_all_weekly_stats():
crud.create_counter("Test2", CounterType.SIMPLE, "020122") crud.create_counter("Test2", CounterType.SIMPLE, "020122")
with connection().session as session: with connection().session as session:
query = text(""" query = text("""
INSERT INTO entries (counter_id, "timestamp", increment) INSERT INTO entries (counter_id, user_id, "timestamp", increment)
VALUES VALUES
(1, date(), 1), (1, 1, date('2026-06-15'), 1),
(1, date(date(), '-7 days'), 2), (1, 1, date(date('2026-06-15'), '-7 days'), 2),
(1, date(date(), '-21 days'), 3), (1, 1, date(date('2026-06-15'), '-21 days'), 3),
(2, date(), 2), (2, 1, date('2026-06-15'), 2),
(2, date(date(), '-14 days'), 4), (2, 1, date(date('2026-06-15'), '-14 days'), 4),
(2, date(date(), '-21 days'), 6) (2, 1, date(date('2026-06-15'), '-21 days'), 6)
""") """)
session.execute(query) session.execute(query)
session.commit() session.commit()
stats = weekly_stats.get_all_weekly_analytics() stats = weekly_stats.get_all_weekly_analytics('2026-06-15')
assert json.loads(stats[::-1]["counters"].iloc[0])["Test"] == 1 assert json.loads(stats[::-1]["counters"].iloc[0])["Test"] == 1
assert json.loads(stats[::-1]["counters"].iloc[0])["Test2"] == 2 assert json.loads(stats[::-1]["counters"].iloc[0])["Test2"] == 2
assert json.loads(stats[::-1]["counters"].iloc[1])["Test"] == 2 assert json.loads(stats[::-1]["counters"].iloc[1])["Test"] == 2
@@ -179,16 +179,16 @@ def test_weekly_stats():
crud.create_counter("Test", CounterType.SIMPLE, "020122") crud.create_counter("Test", CounterType.SIMPLE, "020122")
with connection().session as session: with connection().session as session:
query = text(""" query = text("""
INSERT INTO entries (counter_id, "timestamp", increment) INSERT INTO entries (counter_id, user_id, "timestamp", increment)
VALUES VALUES
(1, date(), 1), (1, 1, date('2026-06-15'), 1),
(1, date(date(), '-7 days'), 2), (1, 1, date(date('2026-06-15'), '-7 days'), 2),
(1, date(date(), '-21 days'), 3) (1, 1, date(date('2026-06-15'), '-21 days'), 3)
""") """)
session.execute(query) session.execute(query)
session.commit() session.commit()
stats = weekly_stats.get_weekly_analytics(1) stats = weekly_stats.get_weekly_analytics(1, '2026-06-15')
assert stats["count"][0] == 1 assert stats["count"][0] == 1
assert stats["count"][1] == 2 assert stats["count"][1] == 2
assert stats["count"][2] == 0 assert stats["count"][2] == 0

View File

@@ -0,0 +1,56 @@
import streamlit
import queries.user as user_query
import user
def test_get_default_user():
users = user_query.find_default_user()
assert len(users) == 1
assert users["email"][0] == "default"
def test_update_default_user_and_find_user():
user_query.update_default_user(email="test@testbase.com", name="Test User", oidc_user_id="1111-2222-3333")
users = user_query.find_default_user()
assert len(users) == 0
users = user_query.find_user_by_oidc_id("1111-2222-3333")
assert len(users) == 1
assert users["email"][0] == "test@testbase.com"
assert users["name"][0] == "Test User"
assert users["oidc_user_id"][0] == "1111-2222-3333"
users = user_query.find_user_by_email("test@testbase.com")
assert len(users) == 1
assert users["email"][0] == "test@testbase.com"
assert users["name"][0] == "Test User"
assert users["oidc_user_id"][0] == "1111-2222-3333"
def test_add_user():
user_query.create_user(email="test@testbase.com", name="Test User", oidc_user_id="333-4444-5555")
users = user_query.find_user_by_oidc_id("333-4444-5555")
assert len(users) == 1
assert users["email"][0] == "test@testbase.com"
assert users["name"][0] == "Test User"
assert users["oidc_user_id"][0] == "333-4444-5555"
users = user_query.find_user_by_email("test@testbase.com")
assert len(users) == 1
assert users["email"][0] == "test@testbase.com"
assert users["name"][0] == "Test User"
assert users["oidc_user_id"][0] == "333-4444-5555"
def test_update_user_in_session():
userInfo = lambda: None
userInfo.email ="test@testbase.com"
userInfo.name = "Test User"
userInfo.sub = "1111-2222-3333"
user.set_user_in_session(userInfo)
state = streamlit.session_state
assert state.user_id == 1
assert state.user_name == userInfo.name
assert state.user_email == userInfo.email
assert state.user_external_id == userInfo.sub

View File

@@ -0,0 +1,35 @@
import queries.crud as crud
def test_change_theme(app):
app.run()
app.switch_page("pages/settings.py").run()
assert app.session_state.current_theme == "light", "Light theme should be default"
assert app.button[0].label == "Light"
assert app.button[0].disabled == True, "Light theme should be selected"
assert app.button[1].label == "Dark"
assert app.button[1].disabled == False, "Dark theme should be de-selected"
app.button[1].click().run()
assert "dark" == crud.get_theme()
assert app.button[0].disabled == False, "Light theme should be de-selected"
assert app.button[1].disabled == True, "Dark theme should be selected"
def test_change_color_palette(app):
app.run()
app.switch_page("pages/settings.py").run()
assert app.button[2].disabled == True, "First palette should be selected"
assert app.button[2].label == "Flames **(selected)**"
app.button[3].click().run()
assert app.button[2].disabled == False, "First palette should be de-selected"
assert app.button[2].label == "Flames"
assert app.button[3].disabled == True, "Second palette should be selected"
assert app.button[3].label == "Water **(selected)**"