diff --git a/factorialhr_analysis/factorialhr_analysis.py b/factorialhr_analysis/factorialhr_analysis.py index 54e6851..524d971 100644 --- a/factorialhr_analysis/factorialhr_analysis.py +++ b/factorialhr_analysis/factorialhr_analysis.py @@ -27,5 +27,6 @@ def frontend_exception_handler(exc: Exception) -> None: app.add_page(pages.index_page, route=routes.INDEX) app.add_page(pages.working_time_verification_page, route=routes.VERIFICATION_ROUTE) +app.add_page(pages.projects_page, route=routes.PROJECTS_ROUTE) app.add_page(pages.authorize_oauth_page, route=routes.OAUTH_AUTHORIZE_ROUTE) app.add_page(pages.start_oauth_process, route=routes.OAUTH_START_ROUTE) diff --git a/factorialhr_analysis/pages/__init__.py b/factorialhr_analysis/pages/__init__.py index 808e599..2b21282 100644 --- a/factorialhr_analysis/pages/__init__.py +++ b/factorialhr_analysis/pages/__init__.py @@ -1,5 +1,12 @@ from factorialhr_analysis.pages.index_page import index_page from factorialhr_analysis.pages.oauth_page import authorize_oauth_page, start_oauth_process +from factorialhr_analysis.pages.projects_page import projects_page from factorialhr_analysis.pages.working_time_verification_page import working_time_verification_page -__all__ = ['authorize_oauth_page', 'index_page', 'start_oauth_process', 'working_time_verification_page'] +__all__ = [ + 'authorize_oauth_page', + 'index_page', + 'projects_page', + 'start_oauth_process', + 'working_time_verification_page', +] diff --git a/factorialhr_analysis/pages/projects_page.py b/factorialhr_analysis/pages/projects_page.py new file mode 100644 index 0000000..d691a1e --- /dev/null +++ b/factorialhr_analysis/pages/projects_page.py @@ -0,0 +1,150 @@ +"""Projects page for displaying project time calculations.""" + +import reflex as rx + +from factorialhr_analysis import components, templates +from factorialhr_analysis.states.project_state import ProjectSettingsState, ProjectState + + +@rx.memo +def render_date_inputs() -> rx.Component: + """Render the date input form.""" + return rx.hstack( + rx.hstack( + rx.text('Start date'), + rx.input( + type='date', + name='start_date', + value=ProjectSettingsState.start_date, + on_change=ProjectSettingsState.set_start_date, + ), + align='center', + spacing='1', + min_width='max-content', + ), + rx.hstack( + rx.text('End date'), + rx.input( + type='date', + name='end_date', + value=ProjectSettingsState.end_date, + on_change=ProjectSettingsState.set_end_date, + ), + align='center', + spacing='1', + min_width='max-content', + ), + rx.checkbox( + 'Only active projects', + default_checked=ProjectSettingsState.active, + on_change=ProjectSettingsState.set_active, + ), + rx.cond( + ProjectSettingsState.date_error, + rx.tooltip( + rx.button('Calculate', disabled=True), + content='End date must be after start date.', + ), + rx.button( + 'Calculate', + loading=ProjectState.is_loading_projects_times | ProjectState.is_loading_unrelated_minutes, + on_click=ProjectState.calculate, + ), + ), + spacing='3', + align='center', + width='100%', + ) + + +@rx.memo +def render_imputed_minutes_chart() -> rx.Component: + """Render vertical bar chart for imputed minutes with different colors per project.""" + return rx.cond( + ProjectState.project_times_data.length() > 0, + rx.vstack( + rx.heading('Project Time by Project', size='4'), + rx.recharts.bar_chart( + rx.recharts.bar( + data_key='total', + name='Total Minutes', + ), + rx.recharts.x_axis(type_='number'), + rx.recharts.y_axis(data_key='project', type_='category'), + rx.recharts.graphing_tooltip(), + rx.recharts.legend(), + data=ProjectState.project_times_data, + layout='vertical', + width='100%', + height=ProjectState.chart_height, + margin={'top': 20, 'right': 20, 'left': 150, 'bottom': 20}, + ), + width='100%', + spacing='4', + ), + rx.text('No data available. Click Calculate to load project data.'), + ) + + +@rx.memo +def render_unrelated_minutes_chart() -> rx.Component: + """Render bar chart for unrelated minutes.""" + return rx.cond( + ProjectState.unrelated_minutes_data.length() > 0, + rx.vstack( + rx.heading('Unrelated Minutes by Employee', size='4'), + rx.text( + rx.cond( + ProjectState.total_unrelated_minutes > 0, + f'Total unrelated minutes: {ProjectState.total_unrelated_minutes}', + 'Total unrelated minutes: 0', + ), + size='5', + weight='bold', + ), + rx.recharts.bar_chart( + rx.recharts.bar( + data_key='minutes', + # fill='#82ca9d', + name='Unrelated Minutes', + ), + rx.recharts.x_axis(data_key='employee'), + rx.recharts.y_axis(), + rx.recharts.graphing_tooltip(), + rx.recharts.legend(), + data=ProjectState.unrelated_minutes_data, + width='100%', + height=400, + ), + width='100%', + spacing='4', + ), + rx.text('No data available. Click Calculate to load unrelated minutes data.'), + ) + + +@components.requires_authentication +@templates.template +def projects_page() -> rx.Component: + """Projects page showing project time calculations.""" + return rx.vstack( + rx.heading('Project Time Analysis', size='6', weight='bold'), + render_date_inputs(), + rx.cond( + ProjectState.error_message.is_not_none(), + rx.callout.root( + rx.callout.icon(), + rx.callout.text(ProjectState.error_message), + color_scheme='red', + ), + rx.fragment(), + ), + rx.vstack( + render_imputed_minutes_chart(), + render_unrelated_minutes_chart(), + spacing='6', + width='100%', + ), + width='100%', + spacing='4', + ) diff --git a/factorialhr_analysis/routes.py b/factorialhr_analysis/routes.py index 441eb52..689e832 100644 --- a/factorialhr_analysis/routes.py +++ b/factorialhr_analysis/routes.py @@ -4,3 +4,4 @@ OAUTH_START_ROUTE = '/oauth/start' OAUTH_AUTHORIZE_ROUTE = '/oauth/authorize' VERIFICATION_ROUTE = '/verification' +PROJECTS_ROUTE = '/projects' diff --git a/factorialhr_analysis/states/__init__.py b/factorialhr_analysis/states/__init__.py index 262be16..db20dcb 100644 --- a/factorialhr_analysis/states/__init__.py +++ b/factorialhr_analysis/states/__init__.py @@ -1,4 +1,5 @@ from factorialhr_analysis.states.data_state import DataState from factorialhr_analysis.states.oauth_state import OAuthSessionState +from factorialhr_analysis.states.project_state import ProjectSettingsState, ProjectState -__all__ = ['DataState', 'OAuthSessionState'] +__all__ = ['DataState', 'OAuthSessionState', 'ProjectSettingsState', 'ProjectState'] diff --git a/factorialhr_analysis/states/data_state.py b/factorialhr_analysis/states/data_state.py index ee47193..bdfa49e 100644 --- a/factorialhr_analysis/states/data_state.py +++ b/factorialhr_analysis/states/data_state.py @@ -2,6 +2,7 @@ import datetime import logging +from collections.abc import Mapping import anyio import factorialhr @@ -13,10 +14,13 @@ class DataState(rx.State): """State for managing data.""" - _employees: dict[int, factorialhr.Employee] = {} # noqa: RUF012 - _teams: dict[int, factorialhr.Team] = {} # noqa: RUF012 - _shifts: dict[int, factorialhr.AttendanceShift] = {} # noqa: RUF012 + _employees: Mapping[int, factorialhr.Employee] = {} + _teams: Mapping[int, factorialhr.Team] = {} + _shifts: Mapping[int, factorialhr.AttendanceShift] = {} _credentials: factorialhr.Credentials | None = None + _time_records: Mapping[int, factorialhr.TimeRecord] = {} + _projects: Mapping[int, factorialhr.Project] = {} + _project_workers: Mapping[int, factorialhr.ProjectWorker] = {} is_loading: rx.Field[bool] = rx.field(default=False) last_updated: rx.Field[datetime.datetime | None] = rx.field(default=None) @@ -47,6 +51,22 @@ async def _load_credentials(self, api_client: factorialhr.ApiClient): async with self: self._credentials = next(iter(credentials.data()), None) + async def _load_time_records(self, api_client: factorialhr.ApiClient): + # all time records are obtained in a single page and therefore requires a high timeout + time_records = await factorialhr.TimeRecordEndpoint(api_client).all(timeout=100) + async with self: + self._time_records = {record.id: record for record in time_records.data()} + + async def _load_projects(self, api_client: factorialhr.ApiClient): + projects = await factorialhr.ProjectEndpoint(api_client).all() + async with self: + self._projects = {project.id: project for project in projects.data()} + + async def _load_project_workers(self, api_client: factorialhr.ApiClient): + project_workers = await factorialhr.ProjectWorkerEndpoint(api_client).all() + async with self: + self._project_workers = {project_worker.id: project_worker for project_worker in project_workers.data()} + @rx.event async def refresh_data(self): # noqa: ANN201 """Refresh the data.""" @@ -68,13 +88,16 @@ async def poll_data(self): auth = (await self.get_state(states.OAuthSessionState)).get_auth() try: async with ( - factorialhr.ApiClient(constants.ENVIRONMENT_URL, auth=auth) as client, # pyright: ignore[reportArgumentType] + factorialhr.ApiClient(constants.ENVIRONMENT_URL, auth=auth) as client, anyio.create_task_group() as tg, ): tg.start_soon(self._load_teams, client) tg.start_soon(self._load_employees, client) tg.start_soon(self._load_shifts, client) tg.start_soon(self._load_credentials, client) + tg.start_soon(self._load_time_records, client) + tg.start_soon(self._load_projects, client) + tg.start_soon(self._load_project_workers, client) except Exception: logging.getLogger(__name__).exception('error loading data') raise @@ -93,3 +116,6 @@ def clear(self): self._teams.clear() self._shifts.clear() self._credentials = None + self._time_records.clear() + self._projects.clear() + self._project_workers.clear() diff --git a/factorialhr_analysis/states/project_state.py b/factorialhr_analysis/states/project_state.py new file mode 100644 index 0000000..1788666 --- /dev/null +++ b/factorialhr_analysis/states/project_state.py @@ -0,0 +1,324 @@ +"""State for managing project calculations.""" + +import collections +import datetime +import logging + +import anyio +import factorialhr +import reflex as rx + +from factorialhr_analysis import states + +DECEMBER = 12 # Month number for December + + +async def _anyio_gather(*coros: collections.abc.Coroutine) -> tuple: + """Gather results from coroutines using anyio.""" + results: list = [] + + async def collect(index: int, coro: collections.abc.Coroutine) -> None: + result = await coro + results.insert(index, result) + + async with anyio.create_task_group() as tg: + for index, coro in enumerate(coros): + tg.start_soon(collect, index, coro) + + return tuple(results) + + +class ProjectSettingsState(rx.State): + """State for managing project settings.""" + + _start_date: datetime.date | None = None + _end_date: datetime.date | None = None + active: rx.Field[bool] = rx.field(default=True) + + @rx.var + def start_date(self) -> str: + """Get the start date as a string.""" + if self._start_date is None: + return '' + return self._start_date.isoformat() + + @rx.var + def end_date(self) -> str: + """Get the end date as a string.""" + if self._end_date is None: + return '' + return self._end_date.isoformat() + + @rx.event + def set_start_date(self, date: str): + """Set the start date.""" + if date: + self._start_date = datetime.date.fromisoformat(date) + else: + self._start_date = None + + @rx.event + def set_end_date(self, date: str): + """Set the end date.""" + if date: + self._end_date = datetime.date.fromisoformat(date) + else: + self._end_date = None + + @rx.var + def date_error(self) -> bool: + """Check if the end date is before the start date.""" + if not self._start_date or not self._end_date: + return False + return self._end_date < self._start_date + + @rx.event + def set_active(self, value: bool): # noqa: FBT001 + """Set the active projects.""" + self.active = value + + +class ProjectState(rx.State): + """State for managing project calculations.""" + + is_loading_projects_times: rx.Field[bool] = rx.field(default=False) + is_loading_unrelated_minutes: rx.Field[bool] = rx.field(default=False) + calculation_type: rx.Field[str] = rx.field(default='imputed') # 'imputed' or 'unrelated' + + @rx.event + def set_calculation_type(self, value: str | list[str]): + """Set the calculation type.""" + # Handle both str and list[str] from segmented_control + self.calculation_type = value if isinstance(value, str) else (value[0] if value else 'imputed') + + # Imputed minutes data + project_times_data: rx.Field[list[dict]] = rx.field(default_factory=list) + employee_names: rx.Field[list[str]] = rx.field(default_factory=list) + project_names: rx.Field[list[str]] = rx.field(default_factory=list) + + @rx.var + def chart_height(self) -> int: + """Calculate chart height based on number of projects.""" + num_projects = len(self.project_names) + if num_projects == 0: + return 400 + # Base height of 200px + 50px per project + return 200 + (num_projects * 50) + + def _validate_calculation_inputs( + self, settings_state: 'ProjectSettingsState', data_state: 'states.DataState' + ) -> str | None: + """Validate inputs for calculation. Returns error message if invalid, None otherwise.""" + if settings_state._start_date is None or settings_state._end_date is None: # noqa: SLF001 + return 'Please select both start and end dates' + if not data_state._time_records: # noqa: SLF001 + return 'No project time records found' + if not data_state._projects: # noqa: SLF001 + return 'No projects found' + return None + + def _process_time_records( + self, + data_state: 'states.DataState', + settings_state: 'ProjectSettingsState', + ) -> tuple[collections.defaultdict[str, collections.defaultdict[str, int]], set[str]]: + """Process time records and calculate project times by employee.""" + project_times_by_employee_name: collections.defaultdict[str, collections.defaultdict[str, int]] = ( + collections.defaultdict(lambda: collections.defaultdict(int)) + ) + contributed_projects: set[str] = set() + + start_date = settings_state._start_date # noqa: SLF001 + end_date = settings_state._end_date # noqa: SLF001 + active_only = settings_state.active + + for record in data_state._time_records.values(): # noqa: SLF001 + if record.date is None or record.date < start_date or record.date > end_date: + continue + project_worker = data_state._project_workers.get(record.project_worker_id) # noqa: SLF001 + if not project_worker: + continue + + if ( + project_worker.project_id in data_state._projects # noqa: SLF001 + and project_worker.employee_id in data_state._employees # noqa: SLF001 + ): + if record.imputed_minutes is None: + continue + employee_name = data_state._employees[project_worker.employee_id].full_name # noqa: SLF001 + project = data_state._projects[project_worker.project_id] # noqa: SLF001 + if active_only and project.status != factorialhr.ProjectStatus.ACTIVE: + continue + project_times_by_employee_name[employee_name][project.name] += record.imputed_minutes or 0 + contributed_projects.add(project.name) + + return project_times_by_employee_name, contributed_projects + + def _prepare_chart_data( + self, + project_times_by_employee_name: collections.defaultdict[str, collections.defaultdict[str, int]], + contributed_projects: set[str], + ) -> tuple[list[dict], list[str], list[str]]: + """Prepare chart data from processed project times.""" + project_times_sum_by_project_name: collections.defaultdict[str, int] = collections.defaultdict(int) + for project_times in project_times_by_employee_name.values(): + for project_name, minutes in project_times.items(): + project_times_sum_by_project_name[project_name] += minutes + + employee_names = sorted(project_times_by_employee_name.keys()) + project_names = sorted(contributed_projects) + + chart_data = [] + for project_name in project_names: + row: dict = { + 'project': project_name, + 'total': project_times_sum_by_project_name[project_name], + } + for employee_name in employee_names: + row[employee_name] = project_times_by_employee_name[employee_name].get(project_name, 0) + chart_data.append(row) + + return chart_data, employee_names, project_names + + # Unrelated minutes data + unrelated_minutes_data: rx.Field[list[dict]] = rx.field(default_factory=list) + total_unrelated_minutes: rx.Field[int] = rx.field(default=0) + + error_message: rx.Field[str | None] = rx.field(default=None) + + @rx.event(background=True) + async def calculate_imputed_minutes(self): + """Calculate imputed minutes for projects.""" + async with self: + if self.is_loading_projects_times: + return + self.is_loading_projects_times = True + self.error_message = None + self.project_times_data = [] + self.employee_names = [] + self.project_names = [] + + async with self: + settings_state = await self.get_state(ProjectSettingsState) + data_state = await self.get_state(states.DataState) + + error_message = self._validate_calculation_inputs(settings_state, data_state) + if error_message: + async with self: + self.error_message = error_message + self.is_loading_projects_times = False + return + + project_times_by_employee_name, contributed_projects = self._process_time_records(data_state, settings_state) + + chart_data, employee_names, project_names = self._prepare_chart_data( + project_times_by_employee_name, contributed_projects + ) + + async with self: + self.project_times_data = chart_data + self.employee_names = employee_names + self.project_names = project_names + self.is_loading_projects_times = False + self.error_message = None + + @rx.event(background=True) + async def calculate_unrelated_minutes(self): + """Calculate minutes not assigned to any project.""" + async with self: + if self.is_loading_unrelated_minutes: + return + self.is_loading_unrelated_minutes = True + self.error_message = None + self.unrelated_minutes_data = [] + self.total_unrelated_minutes = 0 + + async with self: + settings_state = await self.get_state(ProjectSettingsState) + data_state = await self.get_state(states.DataState) + + if settings_state._start_date is None or settings_state._end_date is None: # noqa: SLF001 + async with self: + self.error_message = 'Please select both start and end dates' + self.is_loading_unrelated_minutes = False + return + + try: + # Use data from DataState instead of making API calls + start_date = settings_state._start_date # noqa: SLF001 + end_date = settings_state._end_date # noqa: SLF001 + + # Filter shifts by date range + filtered_shifts = [ + shift + for shift in data_state._shifts.values() # noqa: SLF001 + if start_date <= shift.date <= end_date + ] + + # Filter time records by date range and get their shift IDs + filtered_time_records = [ + record + for record in data_state._time_records.values() # noqa: SLF001 + if record.date is not None and start_date <= record.date <= end_date + ] + + if not filtered_shifts: + async with self: + self.error_message = 'No shifts found in selected date range' + self.is_loading_unrelated_minutes = False + return + + if not filtered_time_records: + async with self: + self.error_message = 'No project time records found in selected date range' + self.is_loading_unrelated_minutes = False + return + + # Map time records by shift ID + records_by_shift_id = {record.attendance_shift_id: record for record in filtered_time_records} + + # Find unrelated shifts + unrelated_shifts_by_employee_id: collections.defaultdict[int, list[factorialhr.AttendanceShift]] = ( + collections.defaultdict(list) + ) + + for shift in filtered_shifts: + if ( + shift.id is not None + and shift.id not in records_by_shift_id + and shift.employee_id is not None + and shift.employee_id in data_state._employees # noqa: SLF001 + ): + unrelated_shifts_by_employee_id[shift.employee_id].append(shift) + + # Calculate totals + chart_data = [] + total_minutes = 0 + for eid, shifts_list in unrelated_shifts_by_employee_id.items(): + employee = data_state._employees[eid] # noqa: SLF001 + if settings_state.active and not employee.active: + continue + minutes = sum(shift.minutes for shift in shifts_list) + total_minutes += minutes + chart_data.append({'employee': employee.full_name, 'minutes': minutes}) + + # Sort by minutes descending + chart_data.sort(key=lambda x: x['minutes'], reverse=True) + + async with self: + self.unrelated_minutes_data = chart_data + self.total_unrelated_minutes = total_minutes + self.is_loading_unrelated_minutes = False + self.error_message = None + + except Exception as e: + logging.getLogger(__name__).exception('error calculating unrelated minutes') + async with self: + self.error_message = str(e) + self.is_loading_unrelated_minutes = False + + @rx.event + async def calculate(self): + """Calculate both imputed and unrelated minutes.""" + yield ProjectState.calculate_imputed_minutes + yield ProjectState.calculate_unrelated_minutes diff --git a/factorialhr_analysis/templates.py b/factorialhr_analysis/templates.py index 4fbc3fd..8412b70 100644 --- a/factorialhr_analysis/templates.py +++ b/factorialhr_analysis/templates.py @@ -21,7 +21,6 @@ def page_template() -> rx.Component: padding_left='1em', padding_right='1em', ), - components.footer(), width='100%', ) diff --git a/pyproject.toml b/pyproject.toml index 2a38cb0..f965e18 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,10 +31,10 @@ keywords = [ dependencies = [ "anyio>=4.9.0", "dotenv>=0.9.9", - "factorialhr>=4.1.0", + "factorialhr==5.0.2", "httpx>=0.28.1", "redis>=6.4.0", - "reflex==0.8.9", + "reflex==0.8.17", ] [dependency-groups] diff --git a/uv.lock b/uv.lock index 42372ad..809b9da 100644 --- a/uv.lock +++ b/uv.lock @@ -121,16 +121,16 @@ wheels = [ [[package]] name = "factorialhr" -version = "4.1.0" +version = "5.0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "httpx" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c4/e1/c7fc3e92a296d8b8ae00075ddd9f63edbf874b3aec84ca8228b0a64e4b36/factorialhr-4.1.0.tar.gz", hash = "sha256:594f52d0e134bec2e02fce9282b236a2a8329d163576333f85462177e7aee6cc", size = 75042, upload-time = "2025-08-22T08:36:48.564Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/60/d11d4ed5bf71c5094e4c5888df205a545e82600839604647d2b46c8fdabf/factorialhr-5.0.2.tar.gz", hash = "sha256:fb4a3a6ddeb1de6dd8a95c73def0b333a25b41ed0427ff5d67aa23ba0d568ed3", size = 70790, upload-time = "2025-11-02T14:38:00.135Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/52/3c/8c4ed48d56e149e64977b5d898c4ef9266a9441ec21272302c333f3aa756/factorialhr-4.1.0-py3-none-any.whl", hash = "sha256:b0eee684c32982fd297de2d7fcc8c70a34e856121ec5aa19cd3da8837907563c", size = 100648, upload-time = "2025-08-22T08:36:47.109Z" }, + { url = "https://files.pythonhosted.org/packages/c2/04/51fd378c57976e00f0a7cff8a3bc36488c7326ed38c0a2c5c5d71f62ba19/factorialhr-5.0.2-py3-none-any.whl", hash = "sha256:14663903470126548ec7079214e87df52be8076c41a18bfbe2694bae29c8e431", size = 95514, upload-time = "2025-11-02T14:37:58.948Z" }, ] [[package]] @@ -163,10 +163,10 @@ test = [ requires-dist = [ { name = "anyio", specifier = ">=4.9.0" }, { name = "dotenv", specifier = ">=0.9.9" }, - { name = "factorialhr", specifier = ">=4.1.0" }, + { name = "factorialhr", specifier = "==5.0.2" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "redis", specifier = ">=6.4.0" }, - { name = "reflex", specifier = "==0.8.9" }, + { name = "reflex", specifier = "==0.8.17" }, ] [package.metadata.requires-dev] @@ -185,31 +185,31 @@ test = [ [[package]] name = "granian" -version = "2.5.0" +version = "2.5.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e7/91/6b51c5749a58e5d86063b193c15914700464f0d64eda84178bf432dbbcf9/granian-2.5.0.tar.gz", hash = "sha256:bed0d047c9c0c6c6a5a85ee5b3c7e2683fc63e03ac032eaf3d7654fa96bde102", size = 110336, upload-time = "2025-07-30T18:55:15.161Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c9/d8/c3c8a452f1b590400bb2cdef1ca61da8e9913762884cf3e6ba801fd3fdad/granian-2.5.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:50d4dc74ab763c1bf396cf85d93a8202bf1bfb74150b03f9fd62b600cd0c777c", size = 3026123, upload-time = "2025-07-30T18:53:20.94Z" }, - { url = "https://files.pythonhosted.org/packages/89/f0/e7038189d4e3b5f1e10bc23547b687bcdecdefbddef87013db64efea6800/granian-2.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:31705782cd616b9b70536c1b61b7f15815ebc4dcccdb72f58aa806ba7ac5dfa1", size = 2584469, upload-time = "2025-07-30T18:53:22.579Z" }, - { url = "https://files.pythonhosted.org/packages/6a/2d/718620f393b6030d4a9ac5d1bc66cc5d159fb7f7c60c5d4483fd43902f7d/granian-2.5.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bbc4ebc727202ad4b3073ca8148c2af49904710d6fce84872191b2dd5cd36916", size = 3326593, upload-time = "2025-07-30T18:53:24.288Z" }, - { url = "https://files.pythonhosted.org/packages/07/57/c8b5f014673717e850db6d551057e74e330aadad5250d3f312cee432b1ec/granian-2.5.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:af272218076663280fdc293b7da3adb716f23d54211cefad92fcf7e01b3eed19", size = 2937464, upload-time = "2025-07-30T18:53:25.72Z" }, - { url = "https://files.pythonhosted.org/packages/fe/91/8ae8aa9f0c3bbdf42fada15d623a5ab9fffd38acc243ec5619b6cbd60b9a/granian-2.5.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:36493c4f2b672d027eb11b05ca6660f9fd4944452841d213cb0cb64da869539b", size = 3229316, upload-time = "2025-07-30T18:53:27.262Z" }, - { url = "https://files.pythonhosted.org/packages/a5/8c/6c294cf3d77dc9524530d30057f9b6e334cc12c5414feb604fb277d030a3/granian-2.5.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:afafac4908d5931e4b2c2a09612e063d7ccd05e531f16b7f11e3bccc4ca8972c", size = 3109818, upload-time = "2025-07-30T18:53:29.004Z" }, - { url = "https://files.pythonhosted.org/packages/4a/49/acc3f1e02e35009d9486e4e00d2c951798a8098935d2374f52c7d2728438/granian-2.5.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:fb157c3d66301ffad4113da4c51aed4d56006b9ebe9d0892c682a634b5fff773", size = 3099384, upload-time = "2025-07-30T18:53:30.448Z" }, - { url = "https://files.pythonhosted.org/packages/c1/87/7cdd96fbeabbceea3820736e65bd6d8c0021983605cee26ef1bf2e11e24b/granian-2.5.0-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:879fdeb71fe279175a25d709d95dd2db01eb67cd12d300e51e3dc704ca5e52fd", size = 3468575, upload-time = "2025-07-30T18:53:31.857Z" }, - { url = "https://files.pythonhosted.org/packages/fd/64/bd41efc6bfbca0ff871ce28a13b9e687055dd70913dfce92c4a21a264bf7/granian-2.5.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:74601bda3aedb249a3d5059d48108acfa61d6f71686162bda0bedc013a443efb", size = 3274703, upload-time = "2025-07-30T18:53:33.378Z" }, - { url = "https://files.pythonhosted.org/packages/97/42/c1a8f51d7ce3408a6aeebf68338e0282f0123b65c2fef1d7c202a407062d/granian-2.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:76dc084d1c528683a88c2d1a00786c9bc013b695b1776ad8a3c71419c45e1df0", size = 2321042, upload-time = "2025-07-30T18:53:34.82Z" }, - { url = "https://files.pythonhosted.org/packages/e9/43/af71556ea889c28b8c1c74e9f50a64c040a92bae5e4412b8617638a8aa0e/granian-2.5.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:f371dd9eedae26158901fee3eb934e8fa61491cc78d234470ce364b989c78a1f", size = 2955162, upload-time = "2025-07-30T18:53:36.263Z" }, - { url = "https://files.pythonhosted.org/packages/c2/35/14c2c050f3df95eb054f2a44b41a02c30c8a04dc8cca888330f55c43a436/granian-2.5.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f7bf7ed30bcda9bbc9962f187081c5dfa6aa07e06c3a59486bc573b5def35914", size = 2548356, upload-time = "2025-07-30T18:53:38.116Z" }, - { url = "https://files.pythonhosted.org/packages/79/b3/8944acd78ff37a2effcdaf1d6163179e38c46c67c98bb1a68e93a75eb2c2/granian-2.5.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3152037d799ea97e5736de054a48bf75368fb79b7cfee7e6aa46de1076a43882", size = 3091535, upload-time = "2025-07-30T18:53:39.514Z" }, - { url = "https://files.pythonhosted.org/packages/ce/49/cf0c89eaa41ac81271e3ae33834f71db945cc09dba609f6dc0e75247fd35/granian-2.5.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:9a53151c2d31dbcf1acbe6af89ce0282387614b6401650d511ca4260ba0e03c1", size = 2977716, upload-time = "2025-07-30T18:53:41.03Z" }, - { url = "https://files.pythonhosted.org/packages/47/58/e828bd5a02c412484b4056cef9aa505b014cc0bb1882f5dbdaf26782f147/granian-2.5.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:8f9918bee3c21eb1410f4323440d76eaa0c2d2e6ca4fa3e3a20d07cc54b788f6", size = 3094250, upload-time = "2025-07-30T18:53:42.495Z" }, - { url = "https://files.pythonhosted.org/packages/ab/ba/e70f0de5cd6e5bca15ea5e5bc6c5598d34f9d4e9e6707e79e6edb63f1fac/granian-2.5.0-cp313-cp313t-musllinux_1_1_armv7l.whl", hash = "sha256:c28a34951c1ed8eea97948882bdbc374ce111be5a59293693613d25043ba1313", size = 3458806, upload-time = "2025-07-30T18:53:44.308Z" }, - { url = "https://files.pythonhosted.org/packages/a1/c5/4d45042c86a924703f0c9617859742e929cdfe31b644bb16f9845c75342c/granian-2.5.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:944ea3bd400a7ccc8129835eda65bd6a37f8fb77828f4e6ded2f06827d6ec25f", size = 3254382, upload-time = "2025-07-30T18:53:45.769Z" }, - { url = "https://files.pythonhosted.org/packages/ab/8e/bbe7564e28385ebb35e94dd7127238b9ba5c29ee09791f3f69d77b3985d4/granian-2.5.0-cp313-cp313t-win_amd64.whl", hash = "sha256:9f6d080e45735dd93e4c60a79e42ee9ed37124a9580a08292d83b0961c705e39", size = 2312124, upload-time = "2025-07-30T18:53:47.204Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/21/86/46a636beb83e7f6d2572e00fee3f3f206b16929d688440e2582d47f9377d/granian-2.5.6.tar.gz", hash = "sha256:173960cbb53dfabebced4f3e8de93b66f59294611df69cc986e26d581527efe4", size = 112175, upload-time = "2025-10-29T18:54:52.748Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/50/f7ba5f2771e0e479ace555fa35ce5661579dddd95a1e9b9a9e7fd7983d8b/granian-2.5.6-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:66ec5ff8694c3f4ddd0ad3746976f950a711a29f1a41701a5b3d9bce91e2b5f4", size = 2813849, upload-time = "2025-10-29T18:53:16.85Z" }, + { url = "https://files.pythonhosted.org/packages/a7/ba/a8291a63e6adc68686185d280174df569ec449e8e90dd69cfda837a792e0/granian-2.5.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ecef6730ad6689a3ef9603aaf7dd8d71501bc271ac3fd5989823e50b64252ed1", size = 2536391, upload-time = "2025-10-29T18:53:18.37Z" }, + { url = "https://files.pythonhosted.org/packages/d6/9b/c1ca3dc1c6a65edcd8563ad3254e4f6c6613965f2f3812233b22bf53d070/granian-2.5.6-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8b097fc2f96d57c817381f73954c9170afdaeb178f73278743aa62d279f0e3a2", size = 3029389, upload-time = "2025-10-29T18:53:19.522Z" }, + { url = "https://files.pythonhosted.org/packages/42/20/82427a67e435e2fff697fff412b3599f9ea553968c3eb3b4b5ee6f92740d/granian-2.5.6-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c26b9d7ceb4e76a87bf6abb5b40f96e18da351a35f106d5a989f9075adf7b18a", size = 2846525, upload-time = "2025-10-29T18:53:21.041Z" }, + { url = "https://files.pythonhosted.org/packages/ed/cd/b10fca3fc08e9b0eb2753a82f41a0b8edb87f036c3ab349049b25d4b6690/granian-2.5.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ad743d18a1796e955e127da58055257918f7c02919f26c8659f73d20cb4ff72", size = 3121222, upload-time = "2025-10-29T18:53:22.176Z" }, + { url = "https://files.pythonhosted.org/packages/88/ea/667a67d1234c1fe3a46b3921a284ede7d0dec6184c78fcb20c0fc7fdaa3c/granian-2.5.6-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:66f90772f16df2aaf92f38c2389ff4026a00ac3cc234724343304917160f6eec", size = 2915624, upload-time = "2025-10-29T18:53:23.391Z" }, + { url = "https://files.pythonhosted.org/packages/48/a8/cb2672c4db6cd362674d356361570a121dec8f01bc271944112d1432c059/granian-2.5.6-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:72595eeaf23d5bf30927c7ae4a0d784f6f0bf9b548257da50c8284e8b2e4d33f", size = 2987280, upload-time = "2025-10-29T18:53:24.989Z" }, + { url = "https://files.pythonhosted.org/packages/7a/bf/393c295213c7150ad5357b1dd3c1f4d7f15e53dc155dc108f273ce920a62/granian-2.5.6-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e75ccf8dcfc87956955e4766dbd32513ad7957ad6d0165a58f3e1464b0f83d52", size = 3149423, upload-time = "2025-10-29T18:53:26.148Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e5/93a1002e896ac42b8428874af9f043d043ea354a973b147d4c0bcd2c3514/granian-2.5.6-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:e85a5376b526f83db4e1d6e076274b3acec77d1980a13a7738f8946f15327c0d", size = 3206907, upload-time = "2025-10-29T18:53:27.443Z" }, + { url = "https://files.pythonhosted.org/packages/95/00/8401a9c4d933ff0017a78616d8c123fed8a3d659deb762491a1c71860c88/granian-2.5.6-cp313-cp313-win_amd64.whl", hash = "sha256:20df79707d1eb4cbb331f219a7d4d1a65437c39eb51cbb8cee995948f5792616", size = 2176292, upload-time = "2025-10-29T18:53:28.683Z" }, + { url = "https://files.pythonhosted.org/packages/b2/5a/0ede785bdd38cd3e8dbafbbb47bb42b25bd53e73be3d4d475e5264786f94/granian-2.5.6-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:f7d117feeb0fd44c28322d172a314d788e8cdbea17d812456a7cb438e913d89a", size = 2740765, upload-time = "2025-10-29T18:53:29.87Z" }, + { url = "https://files.pythonhosted.org/packages/7a/24/858da8939329f1e62540e279021bb92c7d9bec36a7136aa08ce7a69cb3d2/granian-2.5.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a7f3e889ca9366a8299f2e8c06c633d50cae4c718ee2a318966216d5c849cbff", size = 2462988, upload-time = "2025-10-29T18:53:31.406Z" }, + { url = "https://files.pythonhosted.org/packages/18/5d/3fd5ccf8ae552f20b4b05684a4033327ae650fe4f00508c9e04eb1a604d5/granian-2.5.6-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:adc0a1b3b93dc0c70416f34c631a8c618cb90b0a4621f01eefe3a93e6dbe1457", size = 3006326, upload-time = "2025-10-29T18:53:32.973Z" }, + { url = "https://files.pythonhosted.org/packages/29/3a/5321bcfbef5a8405916e99920b46feb2aac28545a2d31fce09ebafb19e9b/granian-2.5.6-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:ede187e331f6a10d16842c4b7fcb77bffdb02f6ac7bd75b1a48872334ca0b003", size = 2771674, upload-time = "2025-10-29T18:53:34.2Z" }, + { url = "https://files.pythonhosted.org/packages/9a/cf/82707873fae010342700a5b4f4cfe299104d7cff525e8b357bfa4d362356/granian-2.5.6-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:5f997418c34237cd9457693c8568063073f6e73987c22defd8372e643127cbf5", size = 2977191, upload-time = "2025-10-29T18:53:35.47Z" }, + { url = "https://files.pythonhosted.org/packages/5a/31/87e7b4620bb9236cd15196064328d93340531d4e49c3d3cc14a58e059d60/granian-2.5.6-cp313-cp313t-musllinux_1_1_armv7l.whl", hash = "sha256:fb28d9c44950f40ccb65d0cfdd0586c168220a5ce98b12e449110412dbcbfc70", size = 3137590, upload-time = "2025-10-29T18:53:36.643Z" }, + { url = "https://files.pythonhosted.org/packages/22/7e/d703d1465f69e5b73e2dd2462990a4a338f869a13dc1b5a6caa472d8033c/granian-2.5.6-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:838869122d73135bd0a849c49752dff1bf659631c024a5733c456859663c8a6c", size = 3194830, upload-time = "2025-10-29T18:53:37.785Z" }, + { url = "https://files.pythonhosted.org/packages/94/9f/a09bde3f43d76fac745b1979a45f49d38d1acb20a1037c06ae67c8dae8e4/granian-2.5.6-cp313-cp313t-win_amd64.whl", hash = "sha256:9e2fac64ce671baf7cede830b22be6cb20cc70706474899d0171db74a023af89", size = 2168123, upload-time = "2025-10-29T18:53:38.959Z" }, ] [package.optional-dependencies] @@ -583,7 +583,7 @@ wheels = [ [[package]] name = "reflex" -version = "0.8.9" +version = "0.8.17" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "alembic" }, @@ -604,14 +604,14 @@ dependencies = [ { name = "typing-extensions" }, { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/60/61/5a9dc5a79f919e9a8b75e1d09f80f848469d6a723f2e8555dfc82e9ca500/reflex-0.8.9.tar.gz", hash = "sha256:c9c7c4d23770269e7e2ca04e19d106ffe6d0e5dacc5dc0b5f830958f5b79687e", size = 597102, upload-time = "2025-09-02T20:30:56.563Z" } +sdist = { url = "https://files.pythonhosted.org/packages/34/7a/63722f49d4589638b1d5f95de25141e088b335c9e01073156df90d7a376a/reflex-0.8.17.tar.gz", hash = "sha256:e9d3e241bfa2525e8acca32d2d37397e772ee500ede8fb50aaa4ddf2edd6aaec", size = 554940, upload-time = "2025-10-27T17:47:02.066Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/2c/5f62538dfe482c0847585d71adcccc17d3ab0b2b79b47cb11b58214b8c9d/reflex-0.8.9-py3-none-any.whl", hash = "sha256:244b06078acf60d81515e89835eba9d25f981bd4bd4537fcbca18aac1b0c6135", size = 885247, upload-time = "2025-09-02T20:30:54.683Z" }, + { url = "https://files.pythonhosted.org/packages/fe/a9/ea5e2b5d4d6d8a5941c3dc72ea1ca9cfc0d575f0aee440e2e1effa17899a/reflex-0.8.17-py3-none-any.whl", hash = "sha256:48d670dfbd70cc2242dae5d6555d35419f3611e21246efb1cc30d4a54d2b8fee", size = 795450, upload-time = "2025-10-27T17:46:59.832Z" }, ] [[package]] name = "reflex-hosting-cli" -version = "0.1.55" +version = "0.1.58" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -620,9 +620,9 @@ dependencies = [ { name = "platformdirs" }, { name = "rich" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/80/04/5920b46c3363edf5b4165909c3f87a197ee42d42a33cbd8497479f2cfefd/reflex_hosting_cli-0.1.55.tar.gz", hash = "sha256:f5d5b0c26cefdc7ba3356954b7be6bfa8ec50267c13a2247f6cf18a140e95918", size = 35818, upload-time = "2025-08-14T21:04:55.157Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/96/012f4e3b474403f6c7a69e28197ec72e5e44be9de931b669191a11380729/reflex_hosting_cli-0.1.58.tar.gz", hash = "sha256:c903aa44944a724fbdd0a6cc66bd174f9be32967daaf6092b7204a1ab3a9272f", size = 35663, upload-time = "2025-10-22T18:20:11.811Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/b3/e3e96eec558368cce20d7064862f50849886565187b2dd1df600874799c9/reflex_hosting_cli-0.1.55-py3-none-any.whl", hash = "sha256:84e78715a1f112996a0a1f8c5503958931a7fc6f0f61b7332645b8fde96c3b17", size = 45323, upload-time = "2025-08-14T21:04:53.9Z" }, + { url = "https://files.pythonhosted.org/packages/e4/36/102c9847fafbc8fa428e221c4bc56900bc1c9ff825a3fc8fd0b129951eaf/reflex_hosting_cli-0.1.58-py3-none-any.whl", hash = "sha256:acf7aa59cc1531c4ce4a7b3813c4ab9229207544f64d40fa460b767a406257f4", size = 45222, upload-time = "2025-10-22T18:20:10.828Z" }, ] [[package]] @@ -708,15 +708,15 @@ wheels = [ [[package]] name = "sqlmodel" -version = "0.0.24" +version = "0.0.27" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "sqlalchemy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/86/4b/c2ad0496f5bdc6073d9b4cef52be9c04f2b37a5773441cc6600b1857648b/sqlmodel-0.0.24.tar.gz", hash = "sha256:cc5c7613c1a5533c9c7867e1aab2fd489a76c9e8a061984da11b4e613c182423", size = 116780, upload-time = "2025-03-07T05:43:32.887Z" } +sdist = { url = "https://files.pythonhosted.org/packages/90/5a/693d90866233e837d182da76082a6d4c2303f54d3aaaa5c78e1238c5d863/sqlmodel-0.0.27.tar.gz", hash = "sha256:ad1227f2014a03905aef32e21428640848ac09ff793047744a73dfdd077ff620", size = 118053, upload-time = "2025-10-08T16:39:11.938Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/16/91/484cd2d05569892b7fef7f5ceab3bc89fb0f8a8c0cde1030d383dbc5449c/sqlmodel-0.0.24-py3-none-any.whl", hash = "sha256:6778852f09370908985b667d6a3ab92910d0d5ec88adcaf23dbc242715ff7193", size = 28622, upload-time = "2025-03-07T05:43:30.37Z" }, + { url = "https://files.pythonhosted.org/packages/8c/92/c35e036151fe53822893979f8a13e6f235ae8191f4164a79ae60a95d66aa/sqlmodel-0.0.27-py3-none-any.whl", hash = "sha256:667fe10aa8ff5438134668228dc7d7a08306f4c5c4c7e6ad3ad68defa0e7aa49", size = 29131, upload-time = "2025-10-08T16:39:10.917Z" }, ] [[package]]