diff --git a/backend/src/database/migrations/U1751459866__gitIntegration.sql b/backend/src/database/migrations/U1751459866__gitIntegration.sql new file mode 100644 index 0000000000..c0c7a05921 --- /dev/null +++ b/backend/src/database/migrations/U1751459866__gitIntegration.sql @@ -0,0 +1,19 @@ +DROP TRIGGER IF EXISTS cleanup_orphaned_repositories_trigger ON git."repositoryIntegrations"; + +-- Drop function +DROP FUNCTION IF EXISTS git.cleanup_orphaned_repositories(); + +-- Drop indexes +DROP INDEX IF EXISTS "ix_git_repositoryIntegrations_integrationId"; +DROP INDEX IF EXISTS "ix_git_repositoryIntegrations_repositoryId"; +DROP INDEX IF EXISTS "ix_git_repositories_lastProcessedAt"; +DROP INDEX IF EXISTS "ix_git_repositories_state_priority"; +DROP INDEX IF EXISTS "ix_git_repositories_priority"; +DROP INDEX IF EXISTS "ix_git_repositories_state"; + +-- Drop tables +DROP TABLE IF EXISTS git."repositoryIntegrations"; +DROP TABLE IF EXISTS git.repositories; + +-- Drop schema +DROP SCHEMA IF EXISTS git CASCADE; \ No newline at end of file diff --git a/backend/src/database/migrations/V1751459866__gitIntegration.sql b/backend/src/database/migrations/V1751459866__gitIntegration.sql new file mode 100644 index 0000000000..51b541df9c --- /dev/null +++ b/backend/src/database/migrations/V1751459866__gitIntegration.sql @@ -0,0 +1,80 @@ +-- Create the git schema +CREATE SCHEMA IF NOT EXISTS git; + +-- Main repositories table +CREATE TABLE git.repositories ( + id UUID PRIMARY KEY NOT NULL DEFAULT uuid_generate_v4(), + "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + "deletedAt" TIMESTAMP WITH TIME ZONE, + + -- Repository identification + url VARCHAR(1024) NOT NULL, + + -- Processing state and priority + state VARCHAR(50) NOT NULL DEFAULT 'pending', + priority INTEGER NOT NULL DEFAULT 0, -- 0=urgent, 1=high, 2=normal + + -- Processing metadata + "lastProcessedAt" TIMESTAMP WITH TIME ZONE, + + -- Constraints + UNIQUE (url) +); + +-- Repository to Integration associations (many-to-many) +CREATE TABLE git."repositoryIntegrations" ( + id UUID PRIMARY KEY NOT NULL DEFAULT uuid_generate_v4(), + "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + + "repositoryId" UUID NOT NULL REFERENCES git.repositories (id) ON DELETE CASCADE, + "integrationId" UUID NOT NULL REFERENCES public."integrations" (id) ON DELETE CASCADE, + + -- Constraints + UNIQUE ("repositoryId", "integrationId") +); + +-- Function to clean up orphaned repositories +CREATE OR REPLACE FUNCTION git.cleanup_orphaned_repositories() +RETURNS TRIGGER AS $$ +BEGIN + -- Delete repositories that no longer have any associations + DELETE FROM git.repositories + WHERE id NOT IN ( + SELECT DISTINCT "repositoryId" + FROM git."repositoryIntegrations" + ); + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +-- Trigger to clean up orphaned repositories after association deletion +CREATE TRIGGER cleanup_orphaned_repositories_trigger + AFTER DELETE ON git."repositoryIntegrations" + FOR EACH ROW + EXECUTE FUNCTION git.cleanup_orphaned_repositories(); + + + +-- Create indexes for optimal query performance + +-- Repositories indexes +CREATE INDEX "ix_git_repositories_state" ON git.repositories (state); +CREATE INDEX "ix_git_repositories_priority" ON git.repositories (priority); +CREATE INDEX "ix_git_repositories_state_priority" ON git.repositories (state, priority); +CREATE INDEX "ix_git_repositories_lastProcessedAt" ON git.repositories ("lastProcessedAt"); + +-- Repository Integrations indexes +CREATE INDEX "ix_git_repositoryIntegrations_repositoryId" ON git."repositoryIntegrations" ("repositoryId"); +CREATE INDEX "ix_git_repositoryIntegrations_integrationId" ON git."repositoryIntegrations" ("integrationId"); + + + +-- Add comments for documentation +COMMENT ON SCHEMA git IS 'Schema for git integration system that manages repository processing and integration associations'; +COMMENT ON TABLE git.repositories IS 'Stores git repository metadata and processing state for the git integration system'; +COMMENT ON TABLE git."repositoryIntegrations" IS 'Many-to-many relationship between repositories and integrations'; + +COMMENT ON COLUMN git.repositories.priority IS 'Processing priority: 0=urgent, 1=high, 2=normal'; +COMMENT ON COLUMN git.repositories.state IS 'Current processing state of the repository'; \ No newline at end of file diff --git a/scripts/services/docker/Dockerfile.git_integration b/scripts/services/docker/Dockerfile.git_integration index c7c901c3da..3c5ebfe85f 100644 --- a/scripts/services/docker/Dockerfile.git_integration +++ b/scripts/services/docker/Dockerfile.git_integration @@ -1,4 +1,8 @@ -FROM python:3.13.5-slim-bullseye AS builder +# Base image for both stages +FROM python:3.13.5-slim-bullseye AS base + +# Builder stage: install build dependencies, uv, and dependencies +FROM base AS builder # Install build dependencies RUN apt-get update && apt-get install -y \ @@ -6,7 +10,7 @@ RUN apt-get update && apt-get install -y \ --no-install-recommends \ && rm -rf /var/lib/apt/lists/* -# Install uv from official image +# Copy uv binary from official image COPY --from=ghcr.io/astral-sh/uv:0.7.17 /uv /usr/local/bin/uv WORKDIR /usr/crowd/app @@ -18,20 +22,25 @@ ENV UV_LINK_MODE=copy \ UV_PROJECT_ENVIRONMENT=/usr/crowd/app/.venv \ UV_VENV_PATH=/usr/crowd/app/.venv -# Copy only necessary files for dependency resolution (for better caching) -COPY ./services/apps/git_integration/pyproject.toml ./services/apps/git_integration/uv.lock ./services/apps/git_integration/README.md ./ -COPY ./LICENSE ./ +# Copy only lock, pyproject.toml and License for dependency install caching +COPY ./services/apps/git_integration/pyproject.toml ./services/apps/git_integration/uv.lock ./LICENSE ./ + +# Install dependencies excluding the project itself for better caching +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --frozen --no-install-project --no-dev -# Install dependencies -RUN uv sync --frozen --no-dev +# Copy full source code +COPY ./services/apps/git_integration ./LICENSE ./ -# Copy source code -COPY ./services/apps/git_integration ./ +# Sync full project including the project itself +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --frozen --no-dev -FROM python:3.13.5-slim-bullseye AS runner +# Runner: minimal image with runtime deps and virtualenv only +FROM base AS runner -# Install runtime dependencies +# Install runtime dependencies only RUN apt-get update && apt-get install -y \ ca-certificates \ git \ @@ -40,27 +49,22 @@ RUN apt-get update && apt-get install -y \ && apt-get clean \ && apt-get autoremove -y -# Set Python environment variables -ENV PYTHONUNBUFFERED=1 -ENV PYTHONDONTWRITEBYTECODE=1 -ENV PIP_NO_CACHE_DIR=off +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PIP_NO_CACHE_DIR=off WORKDIR /usr/crowd/app -# Copy the virtual environment from builder stage +# Copy virtual environment and app source from builder COPY --from=builder /usr/crowd/app/.venv /usr/crowd/app/.venv +COPY --from=builder /usr/crowd/app /usr/crowd/app -# Copy the git_integration service from builder stage -COPY --from=builder /usr/crowd/app/ ./ - -# Activate virtual environment by adding it to PATH +# Add virtual environment bin to PATH ENV PATH="/usr/crowd/app/.venv/bin:$PATH" # Make runner script executable RUN chmod +x ./src/runner.sh -# Expose the default port EXPOSE 8085 -# Set the default command to run the server CMD ["./src/runner.sh"] \ No newline at end of file diff --git a/services/apps/git_integration/LICENSE b/services/apps/git_integration/LICENSE new file mode 120000 index 0000000000..5853aaea53 --- /dev/null +++ b/services/apps/git_integration/LICENSE @@ -0,0 +1 @@ +../../../LICENSE \ No newline at end of file diff --git a/services/apps/git_integration/pyproject.toml b/services/apps/git_integration/pyproject.toml index c855b34050..521cc685ff 100644 --- a/services/apps/git_integration/pyproject.toml +++ b/services/apps/git_integration/pyproject.toml @@ -29,6 +29,7 @@ dependencies = [ "prettytable>=3.11.0", "python-slugify>=8.0.4", "asyncpg", + "loguru>=0.7.3", ] [project.optional-dependencies] diff --git a/services/apps/git_integration/src/crowdgit-cron b/services/apps/git_integration/src/crowdgit-cron deleted file mode 100644 index c710eebc91..0000000000 --- a/services/apps/git_integration/src/crowdgit-cron +++ /dev/null @@ -1 +0,0 @@ -export $(grep -v '^#' /home/ubuntu/git-integration/.env | xargs) && /home/ubuntu/venv/cgit/bin/crowd-git-ingest >> /data/logs/cron.log 2>&1 diff --git a/services/apps/git_integration/src/crowdgit-server b/services/apps/git_integration/src/crowdgit-server deleted file mode 100644 index 91bdd70fd2..0000000000 --- a/services/apps/git_integration/src/crowdgit-server +++ /dev/null @@ -1 +0,0 @@ -source ~/venv/cgit/bin/activate && cd crowdgit && fastapi run server.py \ No newline at end of file diff --git a/services/apps/git_integration/src/crowdgit/database/connection.py b/services/apps/git_integration/src/crowdgit/database/connection.py new file mode 100644 index 0000000000..8a3db52439 --- /dev/null +++ b/services/apps/git_integration/src/crowdgit/database/connection.py @@ -0,0 +1,65 @@ +from typing import Dict, Any, Optional +from contextlib import asynccontextmanager + +import asyncpg +from asyncpg import Pool, Connection +from loguru import logger + +from crowdgit.settings import ( + CROWD_DB_WRITE_HOST, + CROWD_DB_PORT, + CROWD_DB_USERNAME, + CROWD_DB_PASSWORD, + CROWD_DB_DATABASE, +) + +# Global connection pool +_pool: Optional[Pool] = None + + +def get_db_config() -> Dict[str, Any]: + """Get database configuration""" + return { + "database": CROWD_DB_DATABASE, + "user": CROWD_DB_USERNAME, + "password": CROWD_DB_PASSWORD, + "host": CROWD_DB_WRITE_HOST, + "port": CROWD_DB_PORT, + "min_size": 5, + "max_size": 20, + "command_timeout": 120, + "server_settings": {"application_name": "git_integration"}, + } + + +async def get_pool() -> Pool: + """Get or create connection pool""" + global _pool + if _pool is None: + config = get_db_config() + _pool = await asyncpg.create_pool(**config) + logger.info("Created database connection pool") + return _pool + + +@asynccontextmanager +async def get_db_connection() -> Connection: + """Get database connection from pool""" + pool = await get_pool() + + async with pool.acquire() as connection: + try: + yield connection + except Exception as exc: + logger.exception("Database error occurred: {}", exc) + raise + + +async def close_pool(): + """Close connection pool""" + global _pool + + if _pool: + await _pool.close() + _pool = None + logger.info("Closed database connection pool") diff --git a/services/apps/git_integration/src/crowdgit/database/crud.py b/services/apps/git_integration/src/crowdgit/database/crud.py new file mode 100644 index 0000000000..550abd29a8 --- /dev/null +++ b/services/apps/git_integration/src/crowdgit/database/crud.py @@ -0,0 +1,24 @@ +from typing import Dict, Any, Optional +from .registry import fetchval, fetchrow + + +async def insert_repository(url: str, priority: int = 0) -> str: + """Insert a new repository""" + query = """ + INSERT INTO git.repositories (url, priority, state) + VALUES ($1, $2, 'pending') + RETURNING id + """ + result = await fetchval(query, (url, priority)) + return str(result) + + +async def get_repository_by_url(url: str) -> Optional[Dict[str, Any]]: + """Get repository by URL""" + query = """ + SELECT id, url, state, priority, "lastProcessedAt", "createdAt", "updatedAt" + FROM git.repositories + WHERE url = $1 AND "deletedAt" IS NULL + """ + result = await fetchrow(query, (url,)) + return dict(result) if result else None diff --git a/services/apps/git_integration/src/crowdgit/database/registry.py b/services/apps/git_integration/src/crowdgit/database/registry.py new file mode 100644 index 0000000000..0b222969bf --- /dev/null +++ b/services/apps/git_integration/src/crowdgit/database/registry.py @@ -0,0 +1,49 @@ +from typing import List, Dict, Any, Optional +from .connection import get_db_connection +from loguru import logger +from crowdgit.errors import InternalError + +async def query(sql: str, params: tuple = None) -> List[Dict[str, Any]]: + """Execute query with connection pooling""" + try: + async with get_db_connection() as conn: + results = await conn.fetch(sql, *params) if params else await conn.fetch(sql) + return [dict(row) for row in results] + except Exception as error: + logger.error("Database query failed - SQL: {}, Params: {}, Error: {}", sql, params, error) + raise InternalError("Database query failed") + + +async def execute(sql: str, params: tuple = None) -> str: + """Execute write query with connection pooling""" + try: + async with get_db_connection() as conn: + result = await conn.execute(sql, *params) if params else await conn.execute(sql) + return result + except Exception as error: + logger.error("Database write operation failed - SQL: {}, Params: {}, Error: {}", sql, params, error) + raise InternalError("Database execute operation failed") + + + +async def fetchval(sql: str, params: tuple = None) -> Any: + """Execute query and return single value""" + try: + async with get_db_connection() as conn: + result = await conn.fetchval(sql, *params) if params else await conn.fetchval(sql) + return result + except Exception as error: + logger.error("Database fetchval failed - SQL: {}, Params: {}, Error: {}", sql, params, error) + raise InternalError("Database fetchval failed") + + +async def fetchrow(sql: str, params: tuple = None) -> Optional[Dict[str, Any]]: + """Execute query and return single row""" + try: + async with get_db_connection() as conn: + result = await conn.fetchrow(sql, *params) if params else await conn.fetchrow(sql) + return dict(result) if result else None + except Exception as error: + logger.error("Database fetchrow failed - SQL: {}, Params: {}, Error: {}", sql, params, error) + raise InternalError("Database fetchrow failed") + diff --git a/services/apps/git_integration/src/crowdgit/enums.py b/services/apps/git_integration/src/crowdgit/enums.py new file mode 100644 index 0000000000..5e03897a7a --- /dev/null +++ b/services/apps/git_integration/src/crowdgit/enums.py @@ -0,0 +1,26 @@ +from enum import Enum + + +class ErrorCode(str, Enum): + """Standard Error codes""" + + UNKNOWN = "unknown" + INTERNAL = "server-error" + + +class RepositoryState(str, Enum): + """Repository processing states""" + + PENDING = "pending" + PROCESSING = "processing" + COMPLETED = "completed" + FAILED = "failed" + + +class RepositoryPriority(int): + """Repository processing priorities""" + + URGENT = 0 + HIGH = 1 + NORMAL = 2 + LOW = 3 diff --git a/services/apps/git_integration/src/crowdgit/errors.py b/services/apps/git_integration/src/crowdgit/errors.py index 43c21e1497..3706790576 100644 --- a/services/apps/git_integration/src/crowdgit/errors.py +++ b/services/apps/git_integration/src/crowdgit/errors.py @@ -1,8 +1,17 @@ -# -*- coding: utf-8 -*- +from dataclasses import dataclass +from crowdgit.enums import ErrorCode +@dataclass class CrowdGitError(Exception): - pass + error_message: str = "An unknown error occurred" + error_code: ErrorCode | None = ErrorCode.UNKNOWN + + +@dataclass +class InternalError(CrowdGitError): + error_message: str = "Internal error" + error_code: ErrorCode = ErrorCode.INTERNAL class GitRunError(CrowdGitError): diff --git a/services/apps/git_integration/src/crowdgit/models/__init__.py b/services/apps/git_integration/src/crowdgit/models/__init__.py new file mode 100644 index 0000000000..f3d9f4b1ed --- /dev/null +++ b/services/apps/git_integration/src/crowdgit/models/__init__.py @@ -0,0 +1 @@ +# Models package diff --git a/services/apps/git_integration/src/crowdgit/models/repository.py b/services/apps/git_integration/src/crowdgit/models/repository.py new file mode 100644 index 0000000000..1ee47e61a1 --- /dev/null +++ b/services/apps/git_integration/src/crowdgit/models/repository.py @@ -0,0 +1,38 @@ +from datetime import datetime +from typing import Optional +from pydantic import BaseModel, Field +from crowdgit.enums import RepositoryPriority, RepositoryState + + +class Repository(BaseModel): + """Repository model""" + + id: str = Field(..., description="Repository ID") + url: str = Field(..., description="Repository URL") + state: RepositoryState = Field(default=RepositoryState.PENDING, description="Repository state") + priority: int = Field(default=RepositoryPriority.NORMAL, description="Processing priority") + last_processed_at: Optional[datetime] = Field(None, description="Last processing timestamp") + created_at: datetime = Field(..., description="Creation timestamp") + updated_at: datetime = Field(..., description="Last update timestamp") + + class Config: + """Pydantic configuration""" + + from_attributes = True + json_encoders = {datetime: lambda v: v.isoformat() if v else None} + + +class RepositoryCreate(BaseModel): + """Model for creating a new repository""" + + url: str = Field(..., description="Repository URL") + priority: int = Field(default=RepositoryPriority.NORMAL, description="Processing priority") + + +class RepositoryResponse(BaseModel): + """Response model for repository operations""" + + success: bool = Field(..., description="Operation success status") + message: str = Field(..., description="Response message") + data: Optional[Repository] = Field(None, description="Repository data") + error: Optional[str] = Field(None, description="Error message if any") diff --git a/services/apps/git_integration/src/crowdgit/settings.py b/services/apps/git_integration/src/crowdgit/settings.py new file mode 100644 index 0000000000..0ddf2ee998 --- /dev/null +++ b/services/apps/git_integration/src/crowdgit/settings.py @@ -0,0 +1,15 @@ +import os + + +def load_env_var(key: str, required=True, default=None): + value = os.getenv(key, default) + if required and value is None: + raise EnvironmentError(f"Missing required environment variable: {key}") + return value + + +CROWD_DB_WRITE_HOST = load_env_var("CROWD_DB_WRITE_HOST") +CROWD_DB_PORT = load_env_var("CROWD_DB_PORT") +CROWD_DB_USERNAME = load_env_var("CROWD_DB_USERNAME") +CROWD_DB_PASSWORD = load_env_var("CROWD_DB_PASSWORD") +CROWD_DB_DATABASE = load_env_var("CROWD_DB_DATABASE") diff --git a/services/apps/git_integration/src/install.sh b/services/apps/git_integration/src/install.sh deleted file mode 100755 index 570cbead26..0000000000 --- a/services/apps/git_integration/src/install.sh +++ /dev/null @@ -1,74 +0,0 @@ -#!/usr/bin/env bash - -set -eu -o pipefail - -sudo apt update -sudo apt upgrade -y -sudo apt install -y python3-pip -sudo apt install -y python-is-python3 -sudo apt install -y python3-venv -sudo mkdir -p /data -sudo chown ubuntu /data -mkdir -p /data/repos/log -mkdir -p ~/venv/cgit && python -m venv ~/venv/cgit -source ~/venv/cgit/bin/activate -pip install --upgrade pip -cd ~/git-integration -pip install -e . - -# Move one directory up from the git-integration folder -cd ~ - -# Check if git-integration-environment folder exists -if [ ! -d "git-integration-environment" ]; then - git clone git@github.com:CrowdDotDev/git-integration-environment.git -else - # Pull the latest changes from git-integration-environment repository if it exists - cd git-integration-environment - git pull origin main - cd ~ -fi - -# Move back into the git-integration folder -cd ~ - -if [ "$1" == "prod" ]; then - # Copy the dotenv-prod file as .env, replacing it if it already exists - cp -f ~/git-integration-environment/dotenv-prod .env - cp -f ~/git-integration-environment/dotenv-prod git-integration/.env - echo "The dotenv-prod file has been copied as .env" -else - # Copy the dotenv-staging file as .env, replacing it if it already exists - cp -f ~/git-integration-environment/dotenv-staging .env - cp -f ~/git-integration-environment/dotenv-staging git-integration/env - echo "The dotenv-staging file has been copied as .env" -fi - -echo "Setting up cron job" -# Create a temporary file -touch tmp-cron - -# Check if the user has a crontab -if crontab -l >/dev/null 2>&1; then - # If the cron jobs already exist, skip adding - if crontab -l | grep -q "/home/ubuntu/venv/cgit/bin/crowd-git-ingest" && crontab -l | grep -q "/home/ubuntu/venv/cgit/bin/crowd-git-maintainers"; then - echo "Cron jobs already exist. Nothing to do." - rm tmp-cron - exit 0 - fi - - # Save the current crontab into a temporary file - crontab -l >tmp-cron -fi - -# Append the new cron job entries -echo "0 */5 * * * /home/ubuntu/venv/cgit/bin/crowd-git-ingest >> /data/repos/log/cron.log 2>&1" >>tmp-cron -echo "0 0 * * * /home/ubuntu/venv/cgit/bin/crowd-git-maintainers >> /data/repos/log/maintainers.log 2>&1" >>tmp-cron - -# Install the new crontab -crontab tmp-cron - -# Remove the temporary file -rm tmp-cron - -echo "Cron jobs added successfully." diff --git a/services/apps/git_integration/src/setup.py b/services/apps/git_integration/src/setup.py deleted file mode 100644 index 4bb60690ed..0000000000 --- a/services/apps/git_integration/src/setup.py +++ /dev/null @@ -1,5 +0,0 @@ -from setuptools import setup - - -if __name__ == "__main__": - setup(packages=["crowdgit"]) diff --git a/services/apps/git_integration/uv.lock b/services/apps/git_integration/uv.lock index eb76aad65c..2c31764e1a 100644 --- a/services/apps/git_integration/uv.lock +++ b/services/apps/git_integration/uv.lock @@ -264,6 +264,7 @@ dependencies = [ { name = "fastapi", extra = ["standard"] }, { name = "fuzzywuzzy" }, { name = "gitpython" }, + { name = "loguru" }, { name = "openai" }, { name = "prettytable" }, { name = "python-dotenv" }, @@ -294,6 +295,7 @@ requires-dist = [ { name = "fuzzywuzzy" }, { name = "gitpython" }, { name = "jedi", marker = "extra == 'dev'", specifier = ">=0.18.1" }, + { name = "loguru", specifier = ">=0.7.3" }, { name = "openai", specifier = ">=1.45.0" }, { name = "prettytable", specifier = ">=3.11.0" }, { name = "pylint", marker = "extra == 'dev'", specifier = ">=2.13.9" }, @@ -726,6 +728,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/1e/408fd10217eac0e43aea0604be22b4851a09e03d761d44d4ea12089dd70e/levenshtein-0.27.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:7987ef006a3cf56a4532bd4c90c2d3b7b4ca9ad3bf8ae1ee5713c4a3bdfda913", size = 98045, upload-time = "2025-03-02T19:44:44.527Z" }, ] +[[package]] +name = "loguru" +version = "0.7.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "win32-setctime", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559, upload-time = "2024-12-06T11:20:56.608Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" }, +] + [[package]] name = "markdown-it-py" version = "3.0.0" @@ -1683,6 +1698,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, ] +[[package]] +name = "win32-setctime" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867, upload-time = "2024-12-07T15:28:28.314Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" }, +] + [[package]] name = "yapf" version = "0.43.0"