瀏覽代碼

first commit

Tino 1 月之前
當前提交
ee13fd5eb8
共有 100 個文件被更改,包括 4354 次插入 和 0 次删除
  1. 8 0
      .dockerignore
  2. 19 0
      Dockerfile
  3. 49 0
      HARBOR-ROBOT.md
  4. 97 0
      README.md
  5. 二進制
      db.sqlite3
  6. 0 0
      explorer/__init__.py
  7. 二進制
      explorer/__pycache__/__init__.cpython-312.pyc
  8. 二進制
      explorer/__pycache__/admin.cpython-312.pyc
  9. 二進制
      explorer/__pycache__/apps.cpython-312.pyc
  10. 二進制
      explorer/__pycache__/forms.cpython-312.pyc
  11. 二進制
      explorer/__pycache__/models.cpython-312.pyc
  12. 二進制
      explorer/__pycache__/registry_client.cpython-312.pyc
  13. 二進制
      explorer/__pycache__/urls.cpython-312.pyc
  14. 二進制
      explorer/__pycache__/views.cpython-312.pyc
  15. 9 0
      explorer/admin.py
  16. 7 0
      explorer/apps.py
  17. 32 0
      explorer/forms.py
  18. 33 0
      explorer/migrations/0001_initial.py
  19. 0 0
      explorer/migrations/__init__.py
  20. 二進制
      explorer/migrations/__pycache__/0001_initial.cpython-312.pyc
  21. 二進制
      explorer/migrations/__pycache__/__init__.cpython-312.pyc
  22. 23 0
      explorer/models.py
  23. 244 0
      explorer/registry_client.py
  24. 58 0
      explorer/templates/explorer/base.html
  25. 61 0
      explorer/templates/explorer/dashboard.html
  26. 158 0
      explorer/templates/explorer/image_detail.html
  27. 37 0
      explorer/templates/explorer/login.html
  28. 35 0
      explorer/templates/explorer/registry_confirm_delete.html
  29. 54 0
      explorer/templates/explorer/registry_form.html
  30. 66 0
      explorer/templates/explorer/repositories.html
  31. 66 0
      explorer/templates/explorer/tags.html
  32. 16 0
      explorer/urls.py
  33. 215 0
      explorer/views.py
  34. 46 0
      k8s/deployment.yaml
  35. 4 0
      k8s/namespace.yaml
  36. 12 0
      k8s/pvc.yaml
  37. 8 0
      k8s/secrets.yaml
  38. 34 0
      k8s/service-ingress.yaml
  39. 22 0
      manage.py
  40. 0 0
      regbrowser/__init__.py
  41. 二進制
      regbrowser/__pycache__/__init__.cpython-312.pyc
  42. 二進制
      regbrowser/__pycache__/settings.cpython-312.pyc
  43. 二進制
      regbrowser/__pycache__/urls.cpython-312.pyc
  44. 二進制
      regbrowser/__pycache__/wsgi.cpython-312.pyc
  45. 82 0
      regbrowser/settings.py
  46. 7 0
      regbrowser/urls.py
  47. 5 0
      regbrowser/wsgi.py
  48. 15 0
      renuevaRobot.sh
  49. 5 0
      requirements.txt
  50. 13 0
      run.sh
  51. 247 0
      venv/bin/Activate.ps1
  52. 70 0
      venv/bin/activate
  53. 27 0
      venv/bin/activate.csh
  54. 69 0
      venv/bin/activate.fish
  55. 8 0
      venv/bin/django-admin
  56. 8 0
      venv/bin/idna
  57. 8 0
      venv/bin/normalizer
  58. 8 0
      venv/bin/pip
  59. 8 0
      venv/bin/pip3
  60. 8 0
      venv/bin/pip3.12
  61. 1 0
      venv/bin/python
  62. 1 0
      venv/bin/python3
  63. 1 0
      venv/bin/python3.12
  64. 8 0
      venv/bin/sqlformat
  65. 1 0
      venv/lib/python3.12/site-packages/asgiref-3.12.1.dist-info/INSTALLER
  66. 249 0
      venv/lib/python3.12/site-packages/asgiref-3.12.1.dist-info/METADATA
  67. 27 0
      venv/lib/python3.12/site-packages/asgiref-3.12.1.dist-info/RECORD
  68. 5 0
      venv/lib/python3.12/site-packages/asgiref-3.12.1.dist-info/WHEEL
  69. 27 0
      venv/lib/python3.12/site-packages/asgiref-3.12.1.dist-info/licenses/LICENSE
  70. 1 0
      venv/lib/python3.12/site-packages/asgiref-3.12.1.dist-info/top_level.txt
  71. 1 0
      venv/lib/python3.12/site-packages/asgiref/__init__.py
  72. 二進制
      venv/lib/python3.12/site-packages/asgiref/__pycache__/__init__.cpython-312.pyc
  73. 二進制
      venv/lib/python3.12/site-packages/asgiref/__pycache__/compatibility.cpython-312.pyc
  74. 二進制
      venv/lib/python3.12/site-packages/asgiref/__pycache__/current_thread_executor.cpython-312.pyc
  75. 二進制
      venv/lib/python3.12/site-packages/asgiref/__pycache__/local.cpython-312.pyc
  76. 二進制
      venv/lib/python3.12/site-packages/asgiref/__pycache__/server.cpython-312.pyc
  77. 二進制
      venv/lib/python3.12/site-packages/asgiref/__pycache__/sync.cpython-312.pyc
  78. 二進制
      venv/lib/python3.12/site-packages/asgiref/__pycache__/testing.cpython-312.pyc
  79. 二進制
      venv/lib/python3.12/site-packages/asgiref/__pycache__/timeout.cpython-312.pyc
  80. 二進制
      venv/lib/python3.12/site-packages/asgiref/__pycache__/typing.cpython-312.pyc
  81. 二進制
      venv/lib/python3.12/site-packages/asgiref/__pycache__/wsgi.cpython-312.pyc
  82. 48 0
      venv/lib/python3.12/site-packages/asgiref/compatibility.py
  83. 118 0
      venv/lib/python3.12/site-packages/asgiref/current_thread_executor.py
  84. 168 0
      venv/lib/python3.12/site-packages/asgiref/local.py
  85. 0 0
      venv/lib/python3.12/site-packages/asgiref/py.typed
  86. 172 0
      venv/lib/python3.12/site-packages/asgiref/server.py
  87. 667 0
      venv/lib/python3.12/site-packages/asgiref/sync.py
  88. 137 0
      venv/lib/python3.12/site-packages/asgiref/testing.py
  89. 118 0
      venv/lib/python3.12/site-packages/asgiref/timeout.py
  90. 267 0
      venv/lib/python3.12/site-packages/asgiref/typing.py
  91. 197 0
      venv/lib/python3.12/site-packages/asgiref/wsgi.py
  92. 1 0
      venv/lib/python3.12/site-packages/certifi-2026.7.22.dist-info/INSTALLER
  93. 78 0
      venv/lib/python3.12/site-packages/certifi-2026.7.22.dist-info/METADATA
  94. 18 0
      venv/lib/python3.12/site-packages/certifi-2026.7.22.dist-info/RECORD
  95. 5 0
      venv/lib/python3.12/site-packages/certifi-2026.7.22.dist-info/WHEEL
  96. 20 0
      venv/lib/python3.12/site-packages/certifi-2026.7.22.dist-info/licenses/LICENSE
  97. 1 0
      venv/lib/python3.12/site-packages/certifi-2026.7.22.dist-info/top_level.txt
  98. 4 0
      venv/lib/python3.12/site-packages/certifi/__init__.py
  99. 12 0
      venv/lib/python3.12/site-packages/certifi/__main__.py
  100. 二進制
      venv/lib/python3.12/site-packages/certifi/__pycache__/__init__.cpython-312.pyc

+ 8 - 0
.dockerignore

@@ -0,0 +1,8 @@
+venv/
+__pycache__/
+*.pyc
+db.sqlite3
+.git/
+k8s/
+README.md
+run.sh

+ 19 - 0
Dockerfile

@@ -0,0 +1,19 @@
+FROM python:3.12-slim
+
+ENV PYTHONDONTWRITEBYTECODE=1 \
+    PYTHONUNBUFFERED=1
+
+WORKDIR /app
+
+COPY requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt
+
+COPY manage.py .
+COPY regbrowser/ regbrowser/
+COPY explorer/ explorer/
+
+RUN python manage.py collectstatic --noinput
+
+EXPOSE 8000
+
+CMD ["sh", "-c", "python manage.py migrate --noinput && gunicorn regbrowser.wsgi:application --bind 0.0.0.0:8000 --workers 2"]

+ 49 - 0
HARBOR-ROBOT.md

@@ -0,0 +1,49 @@
+# Robot Account de Harbor para el pull secret
+
+## El problema
+
+El secret `myregistrykey` del cluster guarda la contraseña personal de Harbor
+(en base64, que no es cifrado). Cualquiera con acceso al cluster puede leerla,
+y si se cambia la contraseña personal en Harbor, el despliegue deja de hacer
+pull sin motivo aparente.
+
+## La solución
+
+Un *robot account* es un usuario artificial de Harbor pensado para máquinas:
+
+- Se crea dentro de un proyecto, con ámbito limitado
+- Permisos mínimos (solo *pull*)
+- Token independiente de la contraseña personal
+- Caducidad configurable
+- Revocable sin afectar al usuario real
+
+## Crear el robot account
+
+1. Entrar en Harbor → proyecto **reymota** → pestaña **Robot Accounts** →
+   **New Robot Account**
+2. Nombre: `regbrowser-pull`
+3. Expiración: según preferencia (p. ej. 1 año)
+4. Permisos: solo **Pull** sobre el proyecto
+5. Copiar el token que se muestra una sola vez (y el nombre de usuario
+   generado, con el formato `robot$reymota+regbrowser-pull`)
+
+## Sustituir el secret en el cluster
+
+```bash
+kubectl delete secret myregistrykey -n regbrowser
+kubectl create secret docker-registry myregistrykey \
+  --docker-server=harbor.reymota.es \
+  --docker-username='robot$reymota+regbrowser-pull' \
+  --docker-password='<token>' \
+  -n regbrowser
+
+# Reiniciar para que haga pull con las nuevas credenciales
+kubectl rollout restart deploy/regbrowser -n regbrowser
+```
+
+## Verificar
+
+```bash
+kubectl get pods -n regbrowser          # STATUS debe ser Running (no ImagePullBackOff)
+kubectl rollout status deploy/regbrowser -n regbrowser
+```

+ 97 - 0
README.md

@@ -0,0 +1,97 @@
+# Registry Browser
+
+Aplicación Django para explorar registries Docker privados con API v2.
+
+## Inicio Rápido (local)
+
+```bash
+# Crear entorno virtual (si no existe)
+python3 -m venv venv
+
+# Activar entorno virtual
+source venv/bin/activate
+
+# Instalar dependencias
+pip install -r requirements.txt
+
+# Ejecutar migraciones
+python manage.py migrate
+
+# Crear usuario administrador
+python manage.py createsuperuser
+
+# Iniciar servidor
+python manage.py runserver
+```
+
+## Despliegue en Kubernetes
+
+La app corre en el cluster como deployment `regbrowser` en el namespace
+`regbrowser`, accesible vía Ingress en **http://regbrowser.reymota.lab**
+(Nginx Proxy Manager → ClusterIP del Service).
+
+Componentes:
+
+| Recurso | Descripción |
+|---|---|
+| `k8s/namespace.yaml` | Namespace `regbrowser` |
+| `k8s/secrets.yaml` | `SECRET_KEY` de Django |
+| `k8s/pvc.yaml` | PVC de 100Mi para la BD SQLite (`/data/db.sqlite3`) |
+| `k8s/deployment.yaml` | Deployment con gunicorn (estrategia Recreate, 1 réplica) |
+| `k8s/service-ingress.yaml` | Service ClusterIP + Ingress nginx |
+
+Variables de entorno soportadas: `SECRET_KEY`, `DEBUG`, `ALLOWED_HOSTS`,
+`SQLITE_PATH`.
+
+### Construir y desplegar una nueva versión
+
+```bash
+# Construir y subir la imagen a Harbor
+docker build -t harbor.reymota.es/reymota/regbrowser:latest .
+docker push harbor.reymota.es/reymota/regbrowser:latest
+
+# Reiniciar el deployment para que haga pull de la nueva imagen
+kubectl rollout restart deploy/regbrowser -n regbrowser
+
+# Ver el estado del rollout y los logs
+kubectl rollout status deploy/regbrowser -n regbrowser
+kubectl logs -f deploy/regbrowser -n regbrowser
+```
+
+Las migraciones se ejecutan automáticamente al arrancar el contenedor.
+
+### Pull secret de Harbor
+
+El deployment usa el secret `myregistrykey` (tipo
+`kubernetes.io/dockerconfigjson`) para descargar la imagen. Para renovarlo
+con otras credenciales:
+
+```bash
+kubectl delete secret myregistrykey -n regbrowser
+kubectl create secret docker-registry myregistrykey \
+  --docker-server=harbor.reymota.es \
+  --docker-username='robot$regbrowser+ci' \
+  --docker-password='<token>' \
+  -n regbrowser
+```
+
+## Uso
+
+1. Accede a http://localhost:8000 (local) o http://regbrowser.reymota.lab (cluster)
+2. Inicia sesión con las credenciales
+3. Agrega un registry Docker privado
+4. Explora repositorios, tags y detalles de imágenes
+
+## Funcionalidades
+
+- ✅ Exploración de repositorios
+- ✅ Visualización de tags
+- ✅ Detalles de imágenes (layers, tamaño, OS, arquitectura)
+- ✅ Soporte para registries multi-arch
+- ✅ Autenticación básica y token bearer
+- ✅ Búsqueda de repositorios y tags
+- ✅ Eliminación de imágenes (si el registry lo permite)
+
+> Nota: los manifests se eliminan por digest y quedan marcados como
+> pendientes; ejecuta periódicamente `registry garbage-collect` en el
+> registry para liberar el espacio en disco.

二進制
db.sqlite3


+ 0 - 0
explorer/__init__.py


二進制
explorer/__pycache__/__init__.cpython-312.pyc


二進制
explorer/__pycache__/admin.cpython-312.pyc


二進制
explorer/__pycache__/apps.cpython-312.pyc


二進制
explorer/__pycache__/forms.cpython-312.pyc


二進制
explorer/__pycache__/models.cpython-312.pyc


二進制
explorer/__pycache__/registry_client.cpython-312.pyc


二進制
explorer/__pycache__/urls.cpython-312.pyc


二進制
explorer/__pycache__/views.cpython-312.pyc


+ 9 - 0
explorer/admin.py

@@ -0,0 +1,9 @@
+from django.contrib import admin
+from .models import RegistryConfig
+
+
+@admin.register(RegistryConfig)
+class RegistryConfigAdmin(admin.ModelAdmin):
+    list_display = ('name', 'url', 'username', 'use_tls', 'verify_ssl', 'created_at')
+    list_filter = ('use_tls', 'verify_ssl')
+    search_fields = ('name', 'url')

+ 7 - 0
explorer/apps.py

@@ -0,0 +1,7 @@
+from django.apps import AppConfig
+
+
+class ExplorerConfig(AppConfig):
+    default_auto_field = 'django.db.models.BigAutoField'
+    name = 'explorer'
+    verbose_name = 'Registry Explorer'

+ 32 - 0
explorer/forms.py

@@ -0,0 +1,32 @@
+from django import forms
+from .models import RegistryConfig
+
+
+class RegistryConfigForm(forms.ModelForm):
+    class Meta:
+        model = RegistryConfig
+        fields = ['name', 'url', 'username', 'password', 'use_tls', 'verify_ssl']
+        widgets = {
+            'password': forms.PasswordInput(attrs={'autocomplete': 'current-password'}),
+        }
+
+
+class RegistryConfigQuickForm(forms.Form):
+    url = forms.URLField(
+        label='URL del Registry',
+        help_text='Ej: https://harbor.example.com',
+        widget=forms.URLInput(attrs={'class': 'form-control', 'placeholder': 'https://registry.example.com'})
+    )
+    username = forms.CharField(
+        required=False,
+        widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Usuario (opcional)'})
+    )
+    password = forms.CharField(
+        required=False,
+        widget=forms.PasswordInput(attrs={'class': 'form-control', 'placeholder': 'Contraseña (opcional)'})
+    )
+    verify_ssl = forms.BooleanField(
+        initial=True,
+        required=False,
+        widget=forms.CheckboxInput(attrs={'class': 'form-check-input'})
+    )

+ 33 - 0
explorer/migrations/0001_initial.py

@@ -0,0 +1,33 @@
+# Generated by Django 6.1 on 2026-08-20 10:52
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    initial = True
+
+    dependencies = [
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='RegistryConfig',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('name', models.CharField(max_length=100, unique=True)),
+                ('url', models.URLField(help_text='URL del registry (ej: https://harbor.example.com)')),
+                ('username', models.CharField(blank=True, default='', max_length=100)),
+                ('password', models.CharField(blank=True, default='', max_length=255)),
+                ('use_tls', models.BooleanField(default=True, verbose_name='Usar TLS')),
+                ('verify_ssl', models.BooleanField(default=True, verbose_name='Verificar SSL')),
+                ('created_at', models.DateTimeField(auto_now_add=True)),
+                ('updated_at', models.DateTimeField(auto_now=True)),
+            ],
+            options={
+                'verbose_name': 'Registry',
+                'verbose_name_plural': 'Registries',
+                'ordering': ['name'],
+            },
+        ),
+    ]

+ 0 - 0
explorer/migrations/__init__.py


二進制
explorer/migrations/__pycache__/0001_initial.cpython-312.pyc


二進制
explorer/migrations/__pycache__/__init__.cpython-312.pyc


+ 23 - 0
explorer/models.py

@@ -0,0 +1,23 @@
+from django.db import models
+
+
+class RegistryConfig(models.Model):
+    name = models.CharField(max_length=100, unique=True)
+    url = models.URLField(help_text='URL del registry (ej: https://harbor.example.com)')
+    username = models.CharField(max_length=100, blank=True, default='')
+    password = models.CharField(max_length=255, blank=True, default='')
+    use_tls = models.BooleanField(default=True, verbose_name='Usar TLS')
+    verify_ssl = models.BooleanField(default=True, verbose_name='Verificar SSL')
+    created_at = models.DateTimeField(auto_now_add=True)
+    updated_at = models.DateTimeField(auto_now=True)
+
+    class Meta:
+        ordering = ['name']
+        verbose_name = 'Registry'
+        verbose_name_plural = 'Registries'
+
+    def __str__(self):
+        return f"{self.name} ({self.url})"
+
+    def has_auth(self):
+        return bool(self.username and self.password)

+ 244 - 0
explorer/registry_client.py

@@ -0,0 +1,244 @@
+import base64
+import requests
+from requests.auth import HTTPBasicAuth
+from urllib.parse import urljoin
+
+
+class RegistryClient:
+    def __init__(self, url, username='', password='', verify_ssl=True):
+        self.base_url = url.rstrip('/')
+        self.username = username
+        self.password = password
+        self.verify_ssl = verify_ssl
+        self.session = requests.Session()
+        self.session.verify = verify_ssl
+        self.token = None
+        self._auth_method = None
+
+        if username and password:
+            self.session.auth = HTTPBasicAuth(username, password)
+
+    def _url(self, path):
+        return f"{self.base_url}{path}"
+
+    def _get_headers(self):
+        headers = {'Accept': 'application/json'}
+        if self.token:
+            headers['Authorization'] = f'Bearer {self.token}'
+        return headers
+
+    def _request(self, method, path, **kwargs):
+        url = self._url(path)
+        kwargs.setdefault('headers', self._get_headers())
+        kwargs.setdefault('verify', self.verify_ssl)
+
+        response = self.session.request(method, url, **kwargs)
+
+        if response.status_code == 401:
+            self._handle_401(response)
+            kwargs['headers'] = self._get_headers()
+            response = self.session.request(method, url, **kwargs)
+
+        response.raise_for_status()
+        return response
+
+    def _handle_401(self, response):
+        www_auth = response.headers.get('WWW-Authenticate', '')
+
+        if 'Bearer' in www_auth:
+            self._extract_token(www_auth)
+        elif 'Basic' in www_auth and self.username and self.password:
+            token_resp = self.session.get(
+                self._url('/v2/'),
+                auth=HTTPBasicAuth(self.username, self.password),
+                verify=self.verify_ssl
+            )
+            token_resp.raise_for_status()
+
+    def _extract_token(self, www_auth):
+        params = {}
+        for part in www_auth.split(','):
+            if '=' in part:
+                key, value = part.split('=', 1)
+                key = key.strip()
+                value = value.strip().strip('"')
+                if key == 'realm':
+                    params['realm'] = value
+                else:
+                    params[key] = value
+
+        if 'realm' in params:
+            token_url = params.pop('realm')
+            token_resp = self.session.get(
+                token_url,
+                params=params,
+                verify=self.verify_ssl
+            )
+            token_resp.raise_for_status()
+            self.token = token_resp.json().get('token')
+
+    def check_connection(self):
+        try:
+            response = self._request('GET', '/v2/')
+            return True, 'Conexión exitosa'
+        except requests.exceptions.ConnectionError as e:
+            return False, f'Error de conexión: {str(e)}'
+        except requests.exceptions.HTTPError as e:
+            return False, f'Error HTTP: {e.response.status_code}'
+        except Exception as e:
+            return False, f'Error: {str(e)}'
+
+    def list_catalog(self):
+        response = self._request('GET', '/v2/_catalog')
+        return response.json().get('repositories', [])
+
+    def list_tags(self, repository):
+        response = self._request('GET', f'/v2/{repository}/tags/list')
+        data = response.json()
+        tags = data.get('tags', [])
+        return sorted(tags) if tags else []
+
+    def get_manifest(self, repository, reference):
+        headers = {
+            'Accept': ', '.join([
+                'application/vnd.docker.distribution.manifest.v2+json',
+                'application/vnd.docker.distribution.manifest.list.v2+json',
+                'application/vnd.oci.image.manifest.v1+json',
+                'application/vnd.oci.image.index.v1+json',
+            ])
+        }
+        response = self._request('GET', f'/v2/{repository}/manifests/{reference}', headers=headers)
+        return response.json(), response.headers
+
+    def get_blob_summaries(self, repository, manifest):
+        layers = manifest.get('layers', [])
+        config = manifest.get('config', {})
+        result = {
+            'layers': [],
+            'config_size': config.get('size', 0),
+            'config_digest': config.get('digest', ''),
+            'total_size': 0,
+        }
+        for layer in layers:
+            digest = layer.get('digest', '')
+            size = layer.get('size', 0)
+            result['layers'].append({
+                'digest': digest,
+                'size': size,
+                'size_human': self._human_size(size),
+            })
+            result['total_size'] += size
+        result['total_size_human'] = self._human_size(result['total_size'])
+        return result
+
+    def get_image_detail(self, repository, reference):
+        manifest, headers = self.get_manifest(repository, reference)
+        media_type = manifest.get('mediaType', '')
+        manifest_list = 'manifest.list' in media_type or 'index' in media_type
+        digest = headers.get('Docker-Content-Digest', headers.get('docker-content-digest', ''))
+
+        result = {
+            'repository': repository,
+            'reference': reference,
+            'digest': digest,
+            'media_type': media_type,
+            'manifest_list': manifest_list,
+            'schema_version': manifest.get('schemaVersion'),
+            'media_type_display': self._media_type_display(media_type),
+        }
+
+        if manifest_list:
+            manifests = manifest.get('manifests', [])
+            result['sub_manifests'] = []
+            for sub in manifests:
+                sub_platform = sub.get('platform', {})
+                result['sub_manifests'].append({
+                    'digest': sub.get('digest', ''),
+                    'size': sub.get('size', 0),
+                    'size_human': self._human_size(sub.get('size', 0)),
+                    'architecture': sub_platform.get('architecture', 'unknown'),
+                    'os': sub_platform.get('os', 'unknown'),
+                })
+        else:
+            config_data = self._get_config(repository, config_digest=manifest.get('config', {}).get('digest', ''))
+            result['architecture'] = config_data.get('architecture', 'unknown')
+            result['os'] = config_data.get('os', 'unknown')
+            result['created'] = config_data.get('created', '')
+            result['author'] = config_data.get('author', '')
+
+            history = config_data.get('history', [])
+            result['history'] = []
+            for h in history:
+                created_by = h.get('created_by', '')
+                if created_by and created_by != '/bin/sh -c #(nop) ADD file:' in created_by:
+                    result['history'].append({
+                        'created_by': created_by[:200] + ('...' if len(created_by) > 200 else ''),
+                    })
+
+            blob_info = self.get_blob_summaries(repository, manifest)
+            result['layers'] = blob_info['layers']
+            result['total_size'] = blob_info['total_size']
+            result['total_size_human'] = blob_info['total_size_human']
+
+        return result
+
+    def _get_config(self, repository, config_digest):
+        if not config_digest:
+            return {}
+        try:
+            response = self._request('GET', f'/v2/{repository}/blobs/{config_digest}')
+            return response.json()
+        except Exception:
+            return {}
+
+    def delete_manifest(self, repository, reference):
+        try:
+            manifest, resp_headers = self.get_manifest(repository, reference)
+            media_type = manifest.get('mediaType', 'application/vnd.docker.distribution.manifest.v2+json')
+            digest = resp_headers.get('Docker-Content-Digest', resp_headers.get('docker-content-digest', ''))
+
+            if not digest:
+                return False, 'No se pudo obtener el digest del manifest. Intenta eliminar desde el panel de administración del registry.'
+
+            response = self._request(
+                'DELETE',
+                f'/v2/{repository}/manifests/{digest}',
+                headers={
+                    'Accept': media_type,
+                },
+            )
+            return True, 'Imagen eliminada correctamente'
+        except requests.exceptions.HTTPError as e:
+            status = e.response.status_code
+            if status == 404:
+                return False, 'Imagen no encontrada (404). Es posible que el registry requiera eliminar por digest. Prueba obtener el digest desde los detalles de la imagen.'
+            elif status == 405:
+                return False, 'El registry no permite eliminación de imágenes (405 Method Not Allowed). Es necesario habilitar el "delete" en la configuración del registry.'
+            elif status == 401 or status == 403:
+                return False, f'No tienes permisos para eliminar imágenes ({status}). Verifica las credenciales y que el usuario tenga permisos de escritura.'
+            else:
+                body = ''
+                try:
+                    body = e.response.text[:300]
+                except Exception:
+                    pass
+                return False, f'Error al eliminar ({status}): {body}'
+        except Exception as e:
+            return False, f'Error: {str(e)}'
+
+    def _media_type_display(self, media_type):
+        types = {
+            'application/vnd.docker.distribution.manifest.v2+json': 'Docker Manifest v2',
+            'application/vnd.docker.distribution.manifest.list.v2+json': 'Docker Manifest List v2',
+            'application/vnd.oci.image.manifest.v1+json': 'OCI Image Manifest v1',
+            'application/vnd.oci.image.index.v1+json': 'OCI Image Index v1',
+        }
+        return types.get(media_type, media_type or 'Desconocido')
+
+    @staticmethod
+    def _human_size(size_bytes):
+        for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
+            if size_bytes < 1024.0:
+                return f"{size_bytes:.1f} {unit}"
+            size_bytes /= 1024.0
+        return f"{size_bytes:.1f} PB"

+ 58 - 0
explorer/templates/explorer/base.html

@@ -0,0 +1,58 @@
+<!DOCTYPE html>
+<html lang="es">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>{% block title %}Registry Browser{% endblock %}</title>
+    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
+    <link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css" rel="stylesheet">
+    <style>
+        body { background-color: #f8f9fa; }
+        .navbar-brand { font-weight: 600; }
+        .card { box-shadow: 0 0.125rem 0.25rem rgba(0,0,0,.075); border: 1px solid rgba(0,0,0,.08); }
+        .registry-card { transition: transform 0.15s ease-in-out; }
+        .registry-card:hover { transform: translateY(-2px); }
+        .badge-size { font-size: 0.75em; }
+        .layer-item { font-family: monospace; font-size: 0.85em; }
+        .tag-badge { font-size: 0.9em; }
+        .digest-text { font-family: monospace; font-size: 0.8em; color: #6c757d; word-break: break-all; }
+        .empty-state { padding: 3rem; text-align: center; color: #6c757d; }
+        .search-box { max-width: 400px; }
+    </style>
+</head>
+<body>
+    <nav class="navbar navbar-expand-lg navbar-dark bg-dark mb-4">
+        <div class="container">
+            <a class="navbar-brand" href="{% url 'dashboard' %}">
+                <i class="bi bi-box-seam"></i> Registry Browser
+            </a>
+            <div class="navbar-nav ms-auto">
+                {% if user.is_authenticated %}
+                <span class="navbar-text me-3">
+                    <i class="bi bi-person-circle"></i> {{ user.username }}
+                </span>
+                <a class="nav-link" href="{% url 'logout' %}">
+                    <i class="bi bi-box-arrow-right"></i> Salir
+                </a>
+                {% endif %}
+            </div>
+        </div>
+    </nav>
+
+    <div class="container">
+        {% if messages %}
+        {% for message in messages %}
+        <div class="alert alert-{{ message.tags }} alert-dismissible fade show" role="alert">
+            {{ message }}
+            <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
+        </div>
+        {% endfor %}
+        {% endif %}
+
+        {% block content %}{% endblock %}
+    </div>
+
+    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
+    {% block extra_js %}{% endblock %}
+</body>
+</html>

+ 61 - 0
explorer/templates/explorer/dashboard.html

@@ -0,0 +1,61 @@
+{% extends "explorer/base.html" %}
+
+{% block title %}Dashboard - Registry Browser{% endblock %}
+
+{% block content %}
+<div class="d-flex justify-content-between align-items-center mb-4">
+    <h2><i class="bi bi-grid"></i> Mis Registries</h2>
+    <a href="{% url 'registry_create' %}" class="btn btn-primary">
+        <i class="bi bi-plus-lg"></i> Agregar Registry
+    </a>
+</div>
+
+{% if registries %}
+<div class="row">
+    {% for registry in registries %}
+    <div class="col-md-6 col-lg-4 mb-4">
+        <div class="card registry-card h-100">
+            <div class="card-body">
+                <h5 class="card-title">
+                    <i class="bi bi-archive"></i> {{ registry.name }}
+                </h5>
+                <p class="card-text text-muted small mb-2">
+                    <i class="bi bi-link-45deg"></i> {{ registry.url }}
+                </p>
+                <p class="card-text">
+                    {% if registry.has_auth %}
+                    <span class="badge bg-success"><i class="bi bi-lock"></i> Autenticado</span>
+                    {% else %}
+                    <span class="badge bg-secondary"><i class="bi bi-unlock"></i> Anónimo</span>
+                    {% endif %}
+                    {% if registry.use_tls %}
+                    <span class="badge bg-info">TLS</span>
+                    {% endif %}
+                </p>
+            </div>
+            <div class="card-footer bg-transparent d-flex gap-2">
+                <a href="{% url 'registry_repositories' pk=registry.pk %}" class="btn btn-sm btn-outline-primary flex-fill">
+                    <i class="bi bi-folder2-open"></i> Explorar
+                </a>
+                <a href="{% url 'registry_edit' pk=registry.pk %}" class="btn btn-sm btn-outline-secondary">
+                    <i class="bi bi-pencil"></i>
+                </a>
+                <a href="{% url 'registry_delete' pk=registry.pk %}" class="btn btn-sm btn-outline-danger">
+                    <i class="bi bi-trash"></i>
+                </a>
+            </div>
+        </div>
+    </div>
+    {% endfor %}
+</div>
+{% else %}
+<div class="empty-state">
+    <i class="bi bi-inbox" style="font-size: 3rem;"></i>
+    <h4 class="mt-3">No hay registries configurados</h4>
+    <p>Agrega tu primer registry Docker privado para comenzar a explorar.</p>
+    <a href="{% url 'registry_create' %}" class="btn btn-primary">
+        <i class="bi bi-plus-lg"></i> Agregar Registry
+    </a>
+</div>
+{% endif %}
+{% endblock %}

+ 158 - 0
explorer/templates/explorer/image_detail.html

@@ -0,0 +1,158 @@
+{% extends "explorer/base.html" %}
+
+{% block title %}{{ repo_name }}:{{ tag }} - Registry Browser{% endblock %}
+
+{% block content %}
+<nav aria-label="breadcrumb">
+    <ol class="breadcrumb">
+        <li class="breadcrumb-item"><a href="{% url 'dashboard' %}">Dashboard</a></li>
+        <li class="breadcrumb-item"><a href="{% url 'registry_repositories' pk=config.pk %}">{{ config.name }}</a></li>
+        <li class="breadcrumb-item"><a href="{% url 'repository_tags' pk=config.pk repo_name=repo_name %}">{{ repo_name }}</a></li>
+        <li class="breadcrumb-item active">{{ tag }}</li>
+    </ol>
+</nav>
+
+<div class="d-flex justify-content-between align-items-center mb-4">
+    <div>
+        <h2><i class="bi bi-tag"></i> {{ repo_name }}:{{ tag }}</h2>
+    </div>
+    <form method="post" action="{% url 'image_delete' pk=config.pk repo_name=repo_name tag=tag %}"
+          onsubmit="return confirm('¿Estás seguro de eliminar esta imagen?')">
+        {% csrf_token %}
+        <button type="submit" class="btn btn-danger btn-sm">
+            <i class="bi bi-trash"></i> Eliminar
+        </button>
+    </form>
+</div>
+
+<div class="row">
+    <div class="col-md-8">
+        <div class="card mb-4">
+            <div class="card-header">
+                <h5 class="mb-0"><i class="bi bi-info-circle"></i> Información General</h5>
+            </div>
+            <div class="card-body">
+                <table class="table table-borderless mb-0">
+                    <tr>
+                        <td class="text-muted" style="width: 150px;">Repositorio</td>
+                        <td><strong>{{ repo_name }}</strong></td>
+                    </tr>
+                    <tr>
+                        <td class="text-muted">Tag / Reference</td>
+                        <td><code>{{ tag }}</code></td>
+                    </tr>
+                    {% if detail.digest %}
+                    <tr>
+                        <td class="text-muted">Digest</td>
+                        <td><code>{{ detail.digest }}</code></td>
+                    </tr>
+                    {% endif %}
+                    <tr>
+                        <td class="text-muted">Tipo de Manifiesto</td>
+                        <td>{{ detail.media_type_display }}</td>
+                    </tr>
+                    {% if detail.architecture %}
+                    <tr>
+                        <td class="text-muted">Arquitectura</td>
+                        <td>{{ detail.architecture }}</td>
+                    </tr>
+                    {% endif %}
+                    {% if detail.os %}
+                    <tr>
+                        <td class="text-muted">Sistema Operativo</td>
+                        <td>{{ detail.os }}</td>
+                    </tr>
+                    {% endif %}
+                    {% if detail.total_size_human %}
+                    <tr>
+                        <td class="text-muted">Tamaño Total</td>
+                        <td><strong>{{ detail.total_size_human }}</strong></td>
+                    </tr>
+                    {% endif %}
+                    {% if detail.created %}
+                    <tr>
+                        <td class="text-muted">Creado</td>
+                        <td>{{ detail.created|truncatechars:25 }}</td>
+                    </tr>
+                    {% endif %}
+                </table>
+            </div>
+        </div>
+
+        {% if detail.sub_manifests %}
+        <div class="card mb-4">
+            <div class="card-header">
+                <h5 class="mb-0"><i class="bi bi-layers"></i> Manifests (Multi-Arch)</h5>
+            </div>
+            <div class="card-body p-0">
+                <table class="table table-hover mb-0">
+                    <thead>
+                        <tr>
+                            <th>Arquitectura</th>
+                            <th>OS</th>
+                            <th>Tamaño</th>
+                            <th>Digest</th>
+                        </tr>
+                    </thead>
+                    <tbody>
+                        {% for m in detail.sub_manifests %}
+                        <tr>
+                            <td><span class="badge bg-primary">{{ m.architecture }}</span></td>
+                            <td><span class="badge bg-secondary">{{ m.os }}</span></td>
+                            <td>{{ m.size_human }}</td>
+                            <td><span class="digest-text">{{ m.digest }}</span></td>
+                        </tr>
+                        {% endfor %}
+                    </tbody>
+                </table>
+            </div>
+        </div>
+        {% endif %}
+
+        {% if detail.layers %}
+        <div class="card mb-4">
+            <div class="card-header">
+                <h5 class="mb-0"><i class="bi bi-layers"></i> Capas (Layers)</h5>
+            </div>
+            <div class="card-body p-0">
+                <table class="table table-hover mb-0">
+                    <thead>
+                        <tr>
+                            <th>#</th>
+                            <th>Tamaño</th>
+                            <th>Digest</th>
+                        </tr>
+                    </thead>
+                    <tbody>
+                        {% for layer in detail.layers %}
+                        <tr>
+                            <td>{{ forloop.counter }}</td>
+                            <td>{{ layer.size_human }}</td>
+                            <td><span class="digest-text">{{ layer.digest }}</span></td>
+                        </tr>
+                        {% endfor %}
+                    </tbody>
+                </table>
+            </div>
+        </div>
+        {% endif %}
+    </div>
+
+    <div class="col-md-4">
+        {% if detail.history %}
+        <div class="card">
+            <div class="card-header">
+                <h5 class="mb-0"><i class="bi bi-clock-history"></i> Historial de Build</h5>
+            </div>
+            <div class="card-body" style="max-height: 600px; overflow-y: auto;">
+                {% for h in detail.history %}
+                <div class="layer-item mb-2 p-2 bg-light rounded">
+                    <code>{{ h.created_by }}</code>
+                </div>
+                {% endfor %}
+            </div>
+        </div>
+        {% endif %}
+    </div>
+</div>
+{% endblock %}

+ 37 - 0
explorer/templates/explorer/login.html

@@ -0,0 +1,37 @@
+{% extends "explorer/base.html" %}
+
+{% block title %}Login - Registry Browser{% endblock %}
+
+{% block content %}
+<div class="row justify-content-center mt-5">
+    <div class="col-md-5">
+        <div class="card">
+            <div class="card-body p-4">
+                <h3 class="card-title text-center mb-4">
+                    <i class="bi bi-box-seam"></i> Registry Browser
+                </h3>
+                <form method="post">
+                    {% csrf_token %}
+                    {% for field in form %}
+                    <div class="mb-3">
+                        <label for="{{ field.id_for_label }}" class="form-label">{{ field.label }}</label>
+                        <input type="{{ field.field.widget.input_type }}"
+                               name="{{ field.html_name }}"
+                               id="{{ field.id_for_label }}"
+                               class="form-control {% if field.errors %}is-invalid{% endif %}"
+                               value="{{ field.value|default:'' }}"
+                               autofocus>
+                        {% if field.errors %}
+                        <div class="invalid-feedback">{{ field.errors.0 }}</div>
+                        {% endif %}
+                    </div>
+                    {% endfor %}
+                    <button type="submit" class="btn btn-primary w-100">
+                        <i class="bi bi-box-arrow-in-right"></i> Iniciar Sesión
+                    </button>
+                </form>
+            </div>
+        </div>
+    </div>
+</div>
+{% endblock %}

+ 35 - 0
explorer/templates/explorer/registry_confirm_delete.html

@@ -0,0 +1,35 @@
+{% extends "explorer/base.html" %}
+
+{% block title %}Eliminar Registry - Registry Browser{% endblock %}
+
+{% block content %}
+<nav aria-label="breadcrumb">
+    <ol class="breadcrumb">
+        <li class="breadcrumb-item"><a href="{% url 'dashboard' %}">Dashboard</a></li>
+        <li class="breadcrumb-item active">Eliminar Registry</li>
+    </ol>
+</nav>
+
+<div class="row justify-content-center">
+    <div class="col-md-6">
+        <div class="card border-danger">
+            <div class="card-header bg-danger text-white">
+                <h5 class="mb-0"><i class="bi bi-exclamation-triangle"></i> Confirmar Eliminación</h5>
+            </div>
+            <div class="card-body">
+                <p>¿Estás seguro de que deseas eliminar el registry <strong>{{ config.name }}</strong>?</p>
+                <p class="text-muted small">{{ config.url }}</p>
+                <form method="post">
+                    {% csrf_token %}
+                    <div class="d-flex gap-2">
+                        <button type="submit" class="btn btn-danger flex-fill">
+                            <i class="bi bi-trash"></i> Eliminar
+                        </button>
+                        <a href="{% url 'dashboard' %}" class="btn btn-outline-secondary">Cancelar</a>
+                    </div>
+                </form>
+            </div>
+        </div>
+    </div>
+</div>
+{% endblock %}

+ 54 - 0
explorer/templates/explorer/registry_form.html

@@ -0,0 +1,54 @@
+{% extends "explorer/base.html" %}
+
+{% block title %}{{ title }} - Registry Browser{% endblock %}
+
+{% block content %}
+<nav aria-label="breadcrumb">
+    <ol class="breadcrumb">
+        <li class="breadcrumb-item"><a href="{% url 'dashboard' %}">Dashboard</a></li>
+        <li class="breadcrumb-item active">{{ title }}</li>
+    </ol>
+</nav>
+
+<div class="row justify-content-center">
+    <div class="col-md-8 col-lg-6">
+        <div class="card">
+            <div class="card-header">
+                <h5 class="mb-0"><i class="bi bi-gear"></i> {{ title }}</h5>
+            </div>
+            <div class="card-body">
+                <form method="post">
+                    {% csrf_token %}
+                    {% for field in form %}
+                    <div class="mb-3">
+                        <label for="{{ field.id_for_label }}" class="form-label">{{ field.label }}</label>
+                        {% if field.name == 'verify_ssl' or field.name == 'use_tls' %}
+                        <div class="form-check">
+                            {{ field }}
+                            <label class="form-check-label" for="{{ field.id_for_label }}">
+                                {{ field.label }}
+                            </label>
+                        </div>
+                        {% else %}
+                        {{ field }}
+                        {% endif %}
+                        {% if field.help_text %}
+                        <div class="form-text">{{ field.help_text }}</div>
+                        {% endif %}
+                        {% if field.errors %}
+                        <div class="text-danger small">{{ field.errors.0 }}</div>
+                        {% endif %}
+                    </div>
+                    {% endfor %}
+                    <div class="d-flex gap-2">
+                        <button type="submit" class="btn btn-primary flex-fill">
+                            <i class="bi bi-check-lg"></i> Guardar
+                        </button>
+                        <a href="{% url 'dashboard' %}" class="btn btn-outline-secondary">Cancelar</a>
+                    </div>
+                </form>
+            </div>
+        </div>
+    </div>
+</div>
+{% endblock %}

+ 66 - 0
explorer/templates/explorer/repositories.html

@@ -0,0 +1,66 @@
+{% extends "explorer/base.html" %}
+
+{% block title %}Repositorios - {{ config.name }}{% endblock %}
+
+{% block content %}
+<nav aria-label="breadcrumb">
+    <ol class="breadcrumb">
+        <li class="breadcrumb-item"><a href="{% url 'dashboard' %}">Dashboard</a></li>
+        <li class="breadcrumb-item active">{{ config.name }}</li>
+    </ol>
+</nav>
+
+<div class="d-flex justify-content-between align-items-center mb-4">
+    <div>
+        <h2><i class="bi bi-folder2-open"></i> {{ config.name }}</h2>
+        <p class="text-muted mb-0">
+            <i class="bi bi-link-45deg"></i> {{ config.url }} · {{ count }} repositorio{{ count|pluralize }}
+        </p>
+    </div>
+</div>
+
+<div class="card mb-4">
+    <div class="card-body">
+        <form method="get" class="d-flex gap-2">
+            <input type="text" name="search" class="form-control search-box"
+                   placeholder="Buscar repositorio..." value="{{ search }}">
+            <button type="submit" class="btn btn-outline-primary">
+                <i class="bi bi-search"></i> Buscar
+            </button>
+            {% if search %}
+            <a href="{% url 'registry_repositories' pk=config.pk %}" class="btn btn-outline-secondary">
+                <i class="bi bi-x-lg"></i> Limpiar
+            </a>
+            {% endif %}
+        </form>
+    </div>
+</div>
+
+{% if repositories %}
+<div class="card">
+    <div class="list-group list-group-flush">
+        {% for repo in repositories %}
+        <a href="{% url 'repository_tags' pk=config.pk repo_name=repo %}"
+           class="list-group-item list-group-item-action d-flex justify-content-between align-items-center">
+            <div>
+                <i class="bi bi-archive me-2"></i>
+                {{ repo }}
+            </div>
+            <i class="bi bi-chevron-right text-muted"></i>
+        </a>
+        {% endfor %}
+    </div>
+</div>
+{% else %}
+<div class="empty-state">
+    <i class="bi bi-inbox" style="font-size: 3rem;"></i>
+    {% if search %}
+    <h4 class="mt-3">No se encontraron repositorios</h4>
+    <p>No hay repositorios que coincidan con "{{ search }}"</p>
+    {% else %}
+    <h4 class="mt-3">No hay repositorios</h4>
+    <p>Este registry no contiene repositorios o no se pudo acceder al catálogo.</p>
+    {% endif %}
+</div>
+{% endif %}
+{% endblock %}

+ 66 - 0
explorer/templates/explorer/tags.html

@@ -0,0 +1,66 @@
+{% extends "explorer/base.html" %}
+
+{% block title %}Tags - {{ repo_name }}{% endblock %}
+
+{% block content %}
+<nav aria-label="breadcrumb">
+    <ol class="breadcrumb">
+        <li class="breadcrumb-item"><a href="{% url 'dashboard' %}">Dashboard</a></li>
+        <li class="breadcrumb-item"><a href="{% url 'registry_repositories' pk=config.pk %}">{{ config.name }}</a></li>
+        <li class="breadcrumb-item active">{{ repo_name }}</li>
+    </ol>
+</nav>
+
+<div class="d-flex justify-content-between align-items-center mb-4">
+    <div>
+        <h2><i class="bi bi-archive"></i> {{ repo_name }}</h2>
+        <p class="text-muted mb-0">{{ count }} tag{{ count|pluralize }}</p>
+    </div>
+</div>
+
+<div class="card mb-4">
+    <div class="card-body">
+        <form method="get" class="d-flex gap-2">
+            <input type="text" name="search" class="form-control search-box"
+                   placeholder="Buscar tag..." value="{{ search }}">
+            <button type="submit" class="btn btn-outline-primary">
+                <i class="bi bi-search"></i> Buscar
+            </button>
+            {% if search %}
+            <a href="{% url 'repository_tags' pk=config.pk repo_name=repo_name %}"
+               class="btn btn-outline-secondary">
+                <i class="bi bi-x-lg"></i> Limpiar
+            </a>
+            {% endif %}
+        </form>
+    </div>
+</div>
+
+{% if tags %}
+<div class="card">
+    <div class="list-group list-group-flush">
+        {% for tag in tags %}
+        <a href="{% url 'image_detail' pk=config.pk repo_name=repo_name tag=tag %}"
+           class="list-group-item list-group-item-action d-flex justify-content-between align-items-center">
+            <div>
+                <i class="bi bi-tag me-2"></i>
+                <span class="tag-badge">{{ tag }}</span>
+            </div>
+            <i class="bi bi-chevron-right text-muted"></i>
+        </a>
+        {% endfor %}
+    </div>
+</div>
+{% else %}
+<div class="empty-state">
+    <i class="bi bi-inbox" style="font-size: 3rem;"></i>
+    {% if search %}
+    <h4 class="mt-3">No se encontraron tags</h4>
+    <p>No hay tags que coincidan con "{{ search }}"</p>
+    {% else %}
+    <h4 class="mt-3">No hay tags</h4>
+    <p>Este repositorio no contiene tags.</p>
+    {% endif %}
+</div>
+{% endif %}
+{% endblock %}

+ 16 - 0
explorer/urls.py

@@ -0,0 +1,16 @@
+from django.urls import path
+from . import views
+
+urlpatterns = [
+    path('login/', views.login_view, name='login'),
+    path('logout/', views.logout_view, name='logout'),
+    path('', views.dashboard, name='dashboard'),
+    path('registry/add/', views.registry_create, name='registry_create'),
+    path('registry/<int:pk>/edit/', views.registry_edit, name='registry_edit'),
+    path('registry/<int:pk>/delete/', views.registry_delete, name='registry_delete'),
+    path('registry/<int:pk>/repositories/', views.registry_repositories, name='registry_repositories'),
+    path('registry/<int:pk>/repo/<path:repo_name>/tags/', views.repository_tags, name='repository_tags'),
+    path('registry/<int:pk>/repo/<path:repo_name>/tag/<path:tag>/delete/', views.image_delete, name='image_delete'),
+    path('registry/<int:pk>/repo/<path:repo_name>/tag/<path:tag>/', views.image_detail, name='image_detail'),
+    path('api/check-connection/', views.api_check_connection, name='api_check_connection'),
+]

+ 215 - 0
explorer/views.py

@@ -0,0 +1,215 @@
+from django.shortcuts import render, redirect, get_object_or_404
+from django.contrib.auth.decorators import login_required
+from django.contrib.auth import login, logout
+from django.contrib.auth.forms import AuthenticationForm
+from django.contrib import messages
+from django.http import JsonResponse
+from .models import RegistryConfig
+from .forms import RegistryConfigForm
+from .registry_client import RegistryClient
+
+
+def login_view(request):
+    if request.user.is_authenticated:
+        return redirect('dashboard')
+
+    if request.method == 'POST':
+        form = AuthenticationForm(request, data=request.POST)
+        if form.is_valid():
+            user = form.get_user()
+            login(request, user)
+            messages.success(request, f'Bienvenido {user.username}')
+            return redirect('dashboard')
+    else:
+        form = AuthenticationForm()
+
+    return render(request, 'explorer/login.html', {'form': form})
+
+
+def logout_view(request):
+    logout(request)
+    messages.info(request, 'Sesión cerrada')
+    return redirect('login')
+
+
+@login_required
+def dashboard(request):
+    registries = RegistryConfig.objects.all()
+    return render(request, 'explorer/dashboard.html', {'registries': registries})
+
+
+@login_required
+def registry_create(request):
+    if request.method == 'POST':
+        form = RegistryConfigForm(request.POST)
+        if form.is_valid():
+            config = form.save(commit=False)
+            client = RegistryClient(
+                url=config.url,
+                username=config.username,
+                password=config.password,
+                verify_ssl=config.verify_ssl
+            )
+            ok, msg = client.check_connection()
+            if ok:
+                config.save()
+                messages.success(request, f'Registry "{config.name}" configurado correctamente')
+                return redirect('dashboard')
+            else:
+                messages.error(request, f'No se pudo conectar: {msg}')
+    else:
+        form = RegistryConfigForm()
+
+    return render(request, 'explorer/registry_form.html', {'form': form, 'title': 'Agregar Registry'})
+
+
+@login_required
+def registry_edit(request, pk):
+    config = get_object_or_404(RegistryConfig, pk=pk)
+    if request.method == 'POST':
+        form = RegistryConfigForm(request.POST, instance=config)
+        if form.is_valid():
+            form.save()
+            messages.success(request, f'Registry "{config.name}" actualizado')
+            return redirect('dashboard')
+    else:
+        form = RegistryConfigForm(instance=config)
+
+    return render(request, 'explorer/registry_form.html', {'form': form, 'title': 'Editar Registry'})
+
+
+@login_required
+def registry_delete(request, pk):
+    config = get_object_or_404(RegistryConfig, pk=pk)
+    if request.method == 'POST':
+        name = config.name
+        config.delete()
+        messages.success(request, f'Registry "{name}" eliminado')
+        return redirect('dashboard')
+    return render(request, 'explorer/registry_confirm_delete.html', {'config': config})
+
+
+@login_required
+def registry_repositories(request, pk):
+    config = get_object_or_404(RegistryConfig, pk=pk)
+    client = RegistryClient(
+        url=config.url,
+        username=config.username,
+        password=config.password,
+        verify_ssl=config.verify_ssl
+    )
+
+    ok, msg = client.check_connection()
+    if not ok:
+        messages.error(request, f'Error de conexión: {msg}')
+        return redirect('dashboard')
+
+    try:
+        repositories = client.list_catalog()
+    except Exception as e:
+        messages.error(request, f'Error al listar repositorios: {str(e)}')
+        repositories = []
+
+    search = request.GET.get('search', '').strip()
+    if search:
+        repositories = [r for r in repositories if search.lower() in r.lower()]
+
+    return render(request, 'explorer/repositories.html', {
+        'config': config,
+        'repositories': repositories,
+        'search': search,
+        'count': len(repositories),
+    })
+
+
+@login_required
+def repository_tags(request, pk, repo_name):
+    config = get_object_or_404(RegistryConfig, pk=pk)
+    client = RegistryClient(
+        url=config.url,
+        username=config.username,
+        password=config.password,
+        verify_ssl=config.verify_ssl
+    )
+
+    try:
+        tags = client.list_tags(repo_name)
+    except Exception as e:
+        messages.error(request, f'Error al listar tags: {str(e)}')
+        tags = []
+
+    search = request.GET.get('search', '').strip()
+    if search:
+        tags = [t for t in tags if search.lower() in t.lower()]
+
+    return render(request, 'explorer/tags.html', {
+        'config': config,
+        'repo_name': repo_name,
+        'tags': tags,
+        'search': search,
+        'count': len(tags),
+    })
+
+
+@login_required
+def image_detail(request, pk, repo_name, tag):
+    config = get_object_or_404(RegistryConfig, pk=pk)
+    client = RegistryClient(
+        url=config.url,
+        username=config.username,
+        password=config.password,
+        verify_ssl=config.verify_ssl
+    )
+
+    try:
+        detail = client.get_image_detail(repo_name, tag)
+    except Exception as e:
+        messages.error(request, f'Error al obtener detalles: {str(e)}')
+        return redirect('repository_tags', pk=pk, repo_name=repo_name)
+
+    return render(request, 'explorer/image_detail.html', {
+        'config': config,
+        'repo_name': repo_name,
+        'tag': tag,
+        'detail': detail,
+    })
+
+
+@login_required
+def image_delete(request, pk, repo_name, tag):
+    config = get_object_or_404(RegistryConfig, pk=pk)
+    if request.method != 'POST':
+        return redirect('image_detail', pk=pk, repo_name=repo_name, tag=tag)
+
+    client = RegistryClient(
+        url=config.url,
+        username=config.username,
+        password=config.password,
+        verify_ssl=config.verify_ssl
+    )
+
+    ok, msg = client.delete_manifest(repo_name, tag)
+    if ok:
+        messages.success(request, msg)
+    else:
+        messages.error(request, msg)
+
+    return redirect('repository_tags', pk=pk, repo_name=repo_name)
+
+
+@login_required
+def api_check_connection(request):
+    if request.method != 'POST':
+        return JsonResponse({'error': 'Método no permitido'}, status=405)
+
+    url = request.POST.get('url', '')
+    username = request.POST.get('username', '')
+    password = request.POST.get('password', '')
+    verify_ssl = request.POST.get('verify_ssl', 'on') == 'on'
+
+    if not url:
+        return JsonResponse({'ok': False, 'message': 'URL requerida'})
+
+    client = RegistryClient(url=url, username=username, password=password, verify_ssl=verify_ssl)
+    ok, msg = client.check_connection()
+    return JsonResponse({'ok': ok, 'message': msg})

+ 46 - 0
k8s/deployment.yaml

@@ -0,0 +1,46 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: regbrowser
+  namespace: regbrowser
+  labels:
+    app: regbrowser
+spec:
+  replicas: 1
+  strategy:
+    type: Recreate
+  selector:
+    matchLabels:
+      app: regbrowser
+  template:
+    metadata:
+      labels:
+        app: regbrowser
+    spec:
+      imagePullSecrets:
+        - name: myregistrykey
+      containers:
+        - name: regbrowser
+          image: harbor.reymota.es/reymota/regbrowser:latest
+          imagePullPolicy: Always
+          ports:
+            - containerPort: 8000
+          env:
+            - name: DEBUG
+              value: "False"
+            - name: ALLOWED_HOSTS
+              value: "regbrowser.reymota.lab"
+            - name: SECRET_KEY
+              valueFrom:
+                secretKeyRef:
+                  name: regbrowser-secrets
+                  key: SECRET_KEY
+            - name: SQLITE_PATH
+              value: /data/db.sqlite3
+          volumeMounts:
+            - name: db
+              mountPath: /data
+      volumes:
+        - name: db
+          persistentVolumeClaim:
+            claimName: regbrowser-db

+ 4 - 0
k8s/namespace.yaml

@@ -0,0 +1,4 @@
+apiVersion: v1
+kind: Namespace
+metadata:
+  name: regbrowser

+ 12 - 0
k8s/pvc.yaml

@@ -0,0 +1,12 @@
+apiVersion: v1
+kind: PersistentVolumeClaim
+metadata:
+  name: regbrowser-db
+  namespace: regbrowser
+spec:
+  accessModes:
+    - ReadWriteOnce
+  storageClassName: local-path
+  resources:
+    requests:
+      storage: 100Mi

+ 8 - 0
k8s/secrets.yaml

@@ -0,0 +1,8 @@
+apiVersion: v1
+kind: Secret
+metadata:
+  name: regbrowser-secrets
+  namespace: regbrowser
+type: Opaque
+stringData:
+  SECRET_KEY: "RELLenado-por-script"

+ 34 - 0
k8s/service-ingress.yaml

@@ -0,0 +1,34 @@
+apiVersion: v1
+kind: Service
+metadata:
+  name: regbrowser
+  namespace: regbrowser
+  labels:
+    app: regbrowser
+spec:
+  type: ClusterIP
+  selector:
+    app: regbrowser
+  ports:
+    - name: http
+      port: 80
+      targetPort: 8000
+---
+apiVersion: networking.k8s.io/v1
+kind: Ingress
+metadata:
+  name: regbrowser
+  namespace: regbrowser
+spec:
+  ingressClassName: nginx
+  rules:
+    - host: regbrowser.reymota.lab
+      http:
+        paths:
+          - path: /
+            pathType: Prefix
+            backend:
+              service:
+                name: regbrowser
+                port:
+                  number: 80

+ 22 - 0
manage.py

@@ -0,0 +1,22 @@
+#!/usr/bin/env python
+"""Django's command-line utility for administrative tasks."""
+import os
+import sys
+
+
+def main():
+    """Run administrative tasks."""
+    os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'regbrowser.settings')
+    try:
+        from django.core.management import execute_from_command_line
+    except ImportError as exc:
+        raise ImportError(
+            "Couldn't import Django. Are you sure it's installed and "
+            "available on your PYTHONPATH environment variable? Did you "
+            "forget to activate a virtual environment?"
+        ) from exc
+    execute_from_command_line(sys.argv)
+
+
+if __name__ == '__main__':
+    main()

+ 0 - 0
regbrowser/__init__.py


二進制
regbrowser/__pycache__/__init__.cpython-312.pyc


二進制
regbrowser/__pycache__/settings.cpython-312.pyc


二進制
regbrowser/__pycache__/urls.cpython-312.pyc


二進制
regbrowser/__pycache__/wsgi.cpython-312.pyc


+ 82 - 0
regbrowser/settings.py

@@ -0,0 +1,82 @@
+import os
+from pathlib import Path
+
+BASE_DIR = Path(__file__).resolve().parent.parent
+
+SECRET_KEY = os.environ.get('SECRET_KEY', 'django-insecure-regbrowser-dev-key-change-in-production')
+
+DEBUG = os.environ.get('DEBUG', 'False').lower() in ('1', 'true', 'yes')
+
+ALLOWED_HOSTS = os.environ.get('ALLOWED_HOSTS', '*').split(',')
+
+INSTALLED_APPS = [
+    'django.contrib.admin',
+    'django.contrib.auth',
+    'django.contrib.contenttypes',
+    'django.contrib.sessions',
+    'django.contrib.messages',
+    'django.contrib.staticfiles',
+    'whitenoise.runserver_nostatic',
+    'explorer',
+]
+
+MIDDLEWARE = [
+    'django.middleware.security.SecurityMiddleware',
+    'whitenoise.middleware.WhiteNoiseMiddleware',
+    'django.contrib.sessions.middleware.SessionMiddleware',
+    'django.middleware.common.CommonMiddleware',
+    'django.middleware.csrf.CsrfViewMiddleware',
+    'django.contrib.auth.middleware.AuthenticationMiddleware',
+    'django.contrib.messages.middleware.MessageMiddleware',
+    'django.middleware.clickjacking.XFrameOptionsMiddleware',
+]
+
+ROOT_URLCONF = 'regbrowser.urls'
+
+TEMPLATES = [
+    {
+        'BACKEND': 'django.template.backends.django.DjangoTemplates',
+        'DIRS': [],
+        'APP_DIRS': True,
+        'OPTIONS': {
+            'context_processors': [
+                'django.template.context_processors.debug',
+                'django.template.context_processors.request',
+                'django.contrib.auth.context_processors.auth',
+                'django.contrib.messages.context_processors.messages',
+            ],
+        },
+    },
+]
+
+WSGI_APPLICATION = 'regbrowser.wsgi.application'
+
+DATABASES = {
+    'default': {
+        'ENGINE': 'django.db.backends.sqlite3',
+        'NAME': os.environ.get('SQLITE_PATH', BASE_DIR / 'db.sqlite3'),
+    }
+}
+
+AUTH_PASSWORD_VALIDATORS = [
+    {'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'},
+    {'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'},
+    {'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'},
+    {'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'},
+]
+
+LANGUAGE_CODE = 'es'
+TIME_ZONE = 'UTC'
+USE_I18N = True
+USE_TZ = True
+
+STATIC_URL = 'static/'
+STATIC_ROOT = os.environ.get('STATIC_ROOT', BASE_DIR / 'staticfiles')
+STORAGES = {
+    'default': {'BACKEND': 'django.core.files.storage.FileSystemStorage'},
+    'staticfiles': {'BACKEND': 'whitenoise.storage.CompressedManifestStaticFilesStorage'},
+}
+DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
+
+LOGIN_URL = '/login/'
+LOGIN_REDIRECT_URL = '/'

+ 7 - 0
regbrowser/urls.py

@@ -0,0 +1,7 @@
+from django.contrib import admin
+from django.urls import path, include
+
+urlpatterns = [
+    path('admin/', admin.site.urls),
+    path('', include('explorer.urls')),
+]

+ 5 - 0
regbrowser/wsgi.py

@@ -0,0 +1,5 @@
+import os
+from django.core.wsgi import get_wsgi_application
+
+os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'regbrowser.settings')
+application = get_wsgi_application()

+ 15 - 0
renuevaRobot.sh

@@ -0,0 +1,15 @@
+
+kubectl delete secret myregistrykey -n regbrowser
+kubectl create secret docker-registry myregistrykey \
+  --docker-server=harbor.reymota.es \
+  --docker-username='robot$reymota+regbrowser-pull' \
+  --docker-password='MP2MMTKY4s1H0PfYvCJo69kMnPtqvBEz' \
+  -n regbrowser
+
+# Reiniciar para que haga pull con las nuevas credenciales
+kubectl rollout restart deploy/regbrowser -n regbrowser
+
+## Verificar
+
+kubectl get pods -n regbrowser          # STATUS debe ser Running (no ImagePullBackOff)
+kubectl rollout status deploy/regbrowser -n regbrowser

+ 5 - 0
requirements.txt

@@ -0,0 +1,5 @@
+Django>=4.2,<5.0
+requests>=2.31.0
+urllib3>=2.0.0
+gunicorn>=22.0.0
+whitenoise>=6.6.0

+ 13 - 0
run.sh

@@ -0,0 +1,13 @@
+#!/bin/bash
+# Script para ejecutar Registry Browser
+cd "$(dirname "$0")"
+
+# Activar entorno virtual
+source venv/bin/activate
+
+# Ejecutar servidor de desarrollo
+echo "Iniciando Registry Browser en http://localhost:8000"
+echo "Usuario: admin"
+echo "Contraseña: admin"
+echo ""
+python manage.py runserver 0.0.0.0:8000

+ 247 - 0
venv/bin/Activate.ps1

@@ -0,0 +1,247 @@
+<#
+.Synopsis
+Activate a Python virtual environment for the current PowerShell session.
+
+.Description
+Pushes the python executable for a virtual environment to the front of the
+$Env:PATH environment variable and sets the prompt to signify that you are
+in a Python virtual environment. Makes use of the command line switches as
+well as the `pyvenv.cfg` file values present in the virtual environment.
+
+.Parameter VenvDir
+Path to the directory that contains the virtual environment to activate. The
+default value for this is the parent of the directory that the Activate.ps1
+script is located within.
+
+.Parameter Prompt
+The prompt prefix to display when this virtual environment is activated. By
+default, this prompt is the name of the virtual environment folder (VenvDir)
+surrounded by parentheses and followed by a single space (ie. '(.venv) ').
+
+.Example
+Activate.ps1
+Activates the Python virtual environment that contains the Activate.ps1 script.
+
+.Example
+Activate.ps1 -Verbose
+Activates the Python virtual environment that contains the Activate.ps1 script,
+and shows extra information about the activation as it executes.
+
+.Example
+Activate.ps1 -VenvDir C:\Users\MyUser\Common\.venv
+Activates the Python virtual environment located in the specified location.
+
+.Example
+Activate.ps1 -Prompt "MyPython"
+Activates the Python virtual environment that contains the Activate.ps1 script,
+and prefixes the current prompt with the specified string (surrounded in
+parentheses) while the virtual environment is active.
+
+.Notes
+On Windows, it may be required to enable this Activate.ps1 script by setting the
+execution policy for the user. You can do this by issuing the following PowerShell
+command:
+
+PS C:\> Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
+
+For more information on Execution Policies: 
+https://go.microsoft.com/fwlink/?LinkID=135170
+
+#>
+Param(
+    [Parameter(Mandatory = $false)]
+    [String]
+    $VenvDir,
+    [Parameter(Mandatory = $false)]
+    [String]
+    $Prompt
+)
+
+<# Function declarations --------------------------------------------------- #>
+
+<#
+.Synopsis
+Remove all shell session elements added by the Activate script, including the
+addition of the virtual environment's Python executable from the beginning of
+the PATH variable.
+
+.Parameter NonDestructive
+If present, do not remove this function from the global namespace for the
+session.
+
+#>
+function global:deactivate ([switch]$NonDestructive) {
+    # Revert to original values
+
+    # The prior prompt:
+    if (Test-Path -Path Function:_OLD_VIRTUAL_PROMPT) {
+        Copy-Item -Path Function:_OLD_VIRTUAL_PROMPT -Destination Function:prompt
+        Remove-Item -Path Function:_OLD_VIRTUAL_PROMPT
+    }
+
+    # The prior PYTHONHOME:
+    if (Test-Path -Path Env:_OLD_VIRTUAL_PYTHONHOME) {
+        Copy-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME -Destination Env:PYTHONHOME
+        Remove-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME
+    }
+
+    # The prior PATH:
+    if (Test-Path -Path Env:_OLD_VIRTUAL_PATH) {
+        Copy-Item -Path Env:_OLD_VIRTUAL_PATH -Destination Env:PATH
+        Remove-Item -Path Env:_OLD_VIRTUAL_PATH
+    }
+
+    # Just remove the VIRTUAL_ENV altogether:
+    if (Test-Path -Path Env:VIRTUAL_ENV) {
+        Remove-Item -Path env:VIRTUAL_ENV
+    }
+
+    # Just remove VIRTUAL_ENV_PROMPT altogether.
+    if (Test-Path -Path Env:VIRTUAL_ENV_PROMPT) {
+        Remove-Item -Path env:VIRTUAL_ENV_PROMPT
+    }
+
+    # Just remove the _PYTHON_VENV_PROMPT_PREFIX altogether:
+    if (Get-Variable -Name "_PYTHON_VENV_PROMPT_PREFIX" -ErrorAction SilentlyContinue) {
+        Remove-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Scope Global -Force
+    }
+
+    # Leave deactivate function in the global namespace if requested:
+    if (-not $NonDestructive) {
+        Remove-Item -Path function:deactivate
+    }
+}
+
+<#
+.Description
+Get-PyVenvConfig parses the values from the pyvenv.cfg file located in the
+given folder, and returns them in a map.
+
+For each line in the pyvenv.cfg file, if that line can be parsed into exactly
+two strings separated by `=` (with any amount of whitespace surrounding the =)
+then it is considered a `key = value` line. The left hand string is the key,
+the right hand is the value.
+
+If the value starts with a `'` or a `"` then the first and last character is
+stripped from the value before being captured.
+
+.Parameter ConfigDir
+Path to the directory that contains the `pyvenv.cfg` file.
+#>
+function Get-PyVenvConfig(
+    [String]
+    $ConfigDir
+) {
+    Write-Verbose "Given ConfigDir=$ConfigDir, obtain values in pyvenv.cfg"
+
+    # Ensure the file exists, and issue a warning if it doesn't (but still allow the function to continue).
+    $pyvenvConfigPath = Join-Path -Resolve -Path $ConfigDir -ChildPath 'pyvenv.cfg' -ErrorAction Continue
+
+    # An empty map will be returned if no config file is found.
+    $pyvenvConfig = @{ }
+
+    if ($pyvenvConfigPath) {
+
+        Write-Verbose "File exists, parse `key = value` lines"
+        $pyvenvConfigContent = Get-Content -Path $pyvenvConfigPath
+
+        $pyvenvConfigContent | ForEach-Object {
+            $keyval = $PSItem -split "\s*=\s*", 2
+            if ($keyval[0] -and $keyval[1]) {
+                $val = $keyval[1]
+
+                # Remove extraneous quotations around a string value.
+                if ("'""".Contains($val.Substring(0, 1))) {
+                    $val = $val.Substring(1, $val.Length - 2)
+                }
+
+                $pyvenvConfig[$keyval[0]] = $val
+                Write-Verbose "Adding Key: '$($keyval[0])'='$val'"
+            }
+        }
+    }
+    return $pyvenvConfig
+}
+
+
+<# Begin Activate script --------------------------------------------------- #>
+
+# Determine the containing directory of this script
+$VenvExecPath = Split-Path -Parent $MyInvocation.MyCommand.Definition
+$VenvExecDir = Get-Item -Path $VenvExecPath
+
+Write-Verbose "Activation script is located in path: '$VenvExecPath'"
+Write-Verbose "VenvExecDir Fullname: '$($VenvExecDir.FullName)"
+Write-Verbose "VenvExecDir Name: '$($VenvExecDir.Name)"
+
+# Set values required in priority: CmdLine, ConfigFile, Default
+# First, get the location of the virtual environment, it might not be
+# VenvExecDir if specified on the command line.
+if ($VenvDir) {
+    Write-Verbose "VenvDir given as parameter, using '$VenvDir' to determine values"
+}
+else {
+    Write-Verbose "VenvDir not given as a parameter, using parent directory name as VenvDir."
+    $VenvDir = $VenvExecDir.Parent.FullName.TrimEnd("\\/")
+    Write-Verbose "VenvDir=$VenvDir"
+}
+
+# Next, read the `pyvenv.cfg` file to determine any required value such
+# as `prompt`.
+$pyvenvCfg = Get-PyVenvConfig -ConfigDir $VenvDir
+
+# Next, set the prompt from the command line, or the config file, or
+# just use the name of the virtual environment folder.
+if ($Prompt) {
+    Write-Verbose "Prompt specified as argument, using '$Prompt'"
+}
+else {
+    Write-Verbose "Prompt not specified as argument to script, checking pyvenv.cfg value"
+    if ($pyvenvCfg -and $pyvenvCfg['prompt']) {
+        Write-Verbose "  Setting based on value in pyvenv.cfg='$($pyvenvCfg['prompt'])'"
+        $Prompt = $pyvenvCfg['prompt'];
+    }
+    else {
+        Write-Verbose "  Setting prompt based on parent's directory's name. (Is the directory name passed to venv module when creating the virtual environment)"
+        Write-Verbose "  Got leaf-name of $VenvDir='$(Split-Path -Path $venvDir -Leaf)'"
+        $Prompt = Split-Path -Path $venvDir -Leaf
+    }
+}
+
+Write-Verbose "Prompt = '$Prompt'"
+Write-Verbose "VenvDir='$VenvDir'"
+
+# Deactivate any currently active virtual environment, but leave the
+# deactivate function in place.
+deactivate -nondestructive
+
+# Now set the environment variable VIRTUAL_ENV, used by many tools to determine
+# that there is an activated venv.
+$env:VIRTUAL_ENV = $VenvDir
+
+if (-not $Env:VIRTUAL_ENV_DISABLE_PROMPT) {
+
+    Write-Verbose "Setting prompt to '$Prompt'"
+
+    # Set the prompt to include the env name
+    # Make sure _OLD_VIRTUAL_PROMPT is global
+    function global:_OLD_VIRTUAL_PROMPT { "" }
+    Copy-Item -Path function:prompt -Destination function:_OLD_VIRTUAL_PROMPT
+    New-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Description "Python virtual environment prompt prefix" -Scope Global -Option ReadOnly -Visibility Public -Value $Prompt
+
+    function global:prompt {
+        Write-Host -NoNewline -ForegroundColor Green "($_PYTHON_VENV_PROMPT_PREFIX) "
+        _OLD_VIRTUAL_PROMPT
+    }
+    $env:VIRTUAL_ENV_PROMPT = $Prompt
+}
+
+# Clear PYTHONHOME
+if (Test-Path -Path Env:PYTHONHOME) {
+    Copy-Item -Path Env:PYTHONHOME -Destination Env:_OLD_VIRTUAL_PYTHONHOME
+    Remove-Item -Path Env:PYTHONHOME
+}
+
+# Add the venv to the PATH
+Copy-Item -Path Env:PATH -Destination Env:_OLD_VIRTUAL_PATH
+$Env:PATH = "$VenvExecDir$([System.IO.Path]::PathSeparator)$Env:PATH"

+ 70 - 0
venv/bin/activate

@@ -0,0 +1,70 @@
+# This file must be used with "source bin/activate" *from bash*
+# You cannot run it directly
+
+deactivate () {
+    # reset old environment variables
+    if [ -n "${_OLD_VIRTUAL_PATH:-}" ] ; then
+        PATH="${_OLD_VIRTUAL_PATH:-}"
+        export PATH
+        unset _OLD_VIRTUAL_PATH
+    fi
+    if [ -n "${_OLD_VIRTUAL_PYTHONHOME:-}" ] ; then
+        PYTHONHOME="${_OLD_VIRTUAL_PYTHONHOME:-}"
+        export PYTHONHOME
+        unset _OLD_VIRTUAL_PYTHONHOME
+    fi
+
+    # Call hash to forget past commands. Without forgetting
+    # past commands the $PATH changes we made may not be respected
+    hash -r 2> /dev/null
+
+    if [ -n "${_OLD_VIRTUAL_PS1:-}" ] ; then
+        PS1="${_OLD_VIRTUAL_PS1:-}"
+        export PS1
+        unset _OLD_VIRTUAL_PS1
+    fi
+
+    unset VIRTUAL_ENV
+    unset VIRTUAL_ENV_PROMPT
+    if [ ! "${1:-}" = "nondestructive" ] ; then
+    # Self destruct!
+        unset -f deactivate
+    fi
+}
+
+# unset irrelevant variables
+deactivate nondestructive
+
+# on Windows, a path can contain colons and backslashes and has to be converted:
+if [ "${OSTYPE:-}" = "cygwin" ] || [ "${OSTYPE:-}" = "msys" ] ; then
+    # transform D:\path\to\venv to /d/path/to/venv on MSYS
+    # and to /cygdrive/d/path/to/venv on Cygwin
+    export VIRTUAL_ENV=$(cygpath /home/creylopez/RegBrowser/regbrowser/venv)
+else
+    # use the path as-is
+    export VIRTUAL_ENV=/home/creylopez/RegBrowser/regbrowser/venv
+fi
+
+_OLD_VIRTUAL_PATH="$PATH"
+PATH="$VIRTUAL_ENV/"bin":$PATH"
+export PATH
+
+# unset PYTHONHOME if set
+# this will fail if PYTHONHOME is set to the empty string (which is bad anyway)
+# could use `if (set -u; : $PYTHONHOME) ;` in bash
+if [ -n "${PYTHONHOME:-}" ] ; then
+    _OLD_VIRTUAL_PYTHONHOME="${PYTHONHOME:-}"
+    unset PYTHONHOME
+fi
+
+if [ -z "${VIRTUAL_ENV_DISABLE_PROMPT:-}" ] ; then
+    _OLD_VIRTUAL_PS1="${PS1:-}"
+    PS1='(venv) '"${PS1:-}"
+    export PS1
+    VIRTUAL_ENV_PROMPT='(venv) '
+    export VIRTUAL_ENV_PROMPT
+fi
+
+# Call hash to forget past commands. Without forgetting
+# past commands the $PATH changes we made may not be respected
+hash -r 2> /dev/null

+ 27 - 0
venv/bin/activate.csh

@@ -0,0 +1,27 @@
+# This file must be used with "source bin/activate.csh" *from csh*.
+# You cannot run it directly.
+
+# Created by Davide Di Blasi <davidedb@gmail.com>.
+# Ported to Python 3.3 venv by Andrew Svetlov <andrew.svetlov@gmail.com>
+
+alias deactivate 'test $?_OLD_VIRTUAL_PATH != 0 && setenv PATH "$_OLD_VIRTUAL_PATH" && unset _OLD_VIRTUAL_PATH; rehash; test $?_OLD_VIRTUAL_PROMPT != 0 && set prompt="$_OLD_VIRTUAL_PROMPT" && unset _OLD_VIRTUAL_PROMPT; unsetenv VIRTUAL_ENV; unsetenv VIRTUAL_ENV_PROMPT; test "\!:*" != "nondestructive" && unalias deactivate'
+
+# Unset irrelevant variables.
+deactivate nondestructive
+
+setenv VIRTUAL_ENV /home/creylopez/RegBrowser/regbrowser/venv
+
+set _OLD_VIRTUAL_PATH="$PATH"
+setenv PATH "$VIRTUAL_ENV/"bin":$PATH"
+
+
+set _OLD_VIRTUAL_PROMPT="$prompt"
+
+if (! "$?VIRTUAL_ENV_DISABLE_PROMPT") then
+    set prompt = '(venv) '"$prompt"
+    setenv VIRTUAL_ENV_PROMPT '(venv) '
+endif
+
+alias pydoc python -m pydoc
+
+rehash

+ 69 - 0
venv/bin/activate.fish

@@ -0,0 +1,69 @@
+# This file must be used with "source <venv>/bin/activate.fish" *from fish*
+# (https://fishshell.com/). You cannot run it directly.
+
+function deactivate  -d "Exit virtual environment and return to normal shell environment"
+    # reset old environment variables
+    if test -n "$_OLD_VIRTUAL_PATH"
+        set -gx PATH $_OLD_VIRTUAL_PATH
+        set -e _OLD_VIRTUAL_PATH
+    end
+    if test -n "$_OLD_VIRTUAL_PYTHONHOME"
+        set -gx PYTHONHOME $_OLD_VIRTUAL_PYTHONHOME
+        set -e _OLD_VIRTUAL_PYTHONHOME
+    end
+
+    if test -n "$_OLD_FISH_PROMPT_OVERRIDE"
+        set -e _OLD_FISH_PROMPT_OVERRIDE
+        # prevents error when using nested fish instances (Issue #93858)
+        if functions -q _old_fish_prompt
+            functions -e fish_prompt
+            functions -c _old_fish_prompt fish_prompt
+            functions -e _old_fish_prompt
+        end
+    end
+
+    set -e VIRTUAL_ENV
+    set -e VIRTUAL_ENV_PROMPT
+    if test "$argv[1]" != "nondestructive"
+        # Self-destruct!
+        functions -e deactivate
+    end
+end
+
+# Unset irrelevant variables.
+deactivate nondestructive
+
+set -gx VIRTUAL_ENV /home/creylopez/RegBrowser/regbrowser/venv
+
+set -gx _OLD_VIRTUAL_PATH $PATH
+set -gx PATH "$VIRTUAL_ENV/"bin $PATH
+
+# Unset PYTHONHOME if set.
+if set -q PYTHONHOME
+    set -gx _OLD_VIRTUAL_PYTHONHOME $PYTHONHOME
+    set -e PYTHONHOME
+end
+
+if test -z "$VIRTUAL_ENV_DISABLE_PROMPT"
+    # fish uses a function instead of an env var to generate the prompt.
+
+    # Save the current fish_prompt function as the function _old_fish_prompt.
+    functions -c fish_prompt _old_fish_prompt
+
+    # With the original prompt function renamed, we can override with our own.
+    function fish_prompt
+        # Save the return status of the last command.
+        set -l old_status $status
+
+        # Output the venv prompt; color taken from the blue of the Python logo.
+        printf "%s%s%s" (set_color 4B8BBE) '(venv) ' (set_color normal)
+
+        # Restore the return status of the previous command.
+        echo "exit $old_status" | .
+        # Output the original/"old" prompt.
+        _old_fish_prompt
+    end
+
+    set -gx _OLD_FISH_PROMPT_OVERRIDE "$VIRTUAL_ENV"
+    set -gx VIRTUAL_ENV_PROMPT '(venv) '
+end

+ 8 - 0
venv/bin/django-admin

@@ -0,0 +1,8 @@
+#!/home/creylopez/RegBrowser/regbrowser/venv/bin/python3
+# -*- coding: utf-8 -*-
+import re
+import sys
+from django.core.management import execute_from_command_line
+if __name__ == '__main__':
+    sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
+    sys.exit(execute_from_command_line())

+ 8 - 0
venv/bin/idna

@@ -0,0 +1,8 @@
+#!/home/creylopez/RegBrowser/regbrowser/venv/bin/python3
+# -*- coding: utf-8 -*-
+import re
+import sys
+from idna.cli import main
+if __name__ == '__main__':
+    sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
+    sys.exit(main())

+ 8 - 0
venv/bin/normalizer

@@ -0,0 +1,8 @@
+#!/home/creylopez/RegBrowser/regbrowser/venv/bin/python3
+# -*- coding: utf-8 -*-
+import re
+import sys
+from charset_normalizer.cli import cli_detect
+if __name__ == '__main__':
+    sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
+    sys.exit(cli_detect())

+ 8 - 0
venv/bin/pip

@@ -0,0 +1,8 @@
+#!/home/creylopez/RegBrowser/regbrowser/venv/bin/python3
+# -*- coding: utf-8 -*-
+import re
+import sys
+from pip._internal.cli.main import main
+if __name__ == '__main__':
+    sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
+    sys.exit(main())

+ 8 - 0
venv/bin/pip3

@@ -0,0 +1,8 @@
+#!/home/creylopez/RegBrowser/regbrowser/venv/bin/python3
+# -*- coding: utf-8 -*-
+import re
+import sys
+from pip._internal.cli.main import main
+if __name__ == '__main__':
+    sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
+    sys.exit(main())

+ 8 - 0
venv/bin/pip3.12

@@ -0,0 +1,8 @@
+#!/home/creylopez/RegBrowser/regbrowser/venv/bin/python3
+# -*- coding: utf-8 -*-
+import re
+import sys
+from pip._internal.cli.main import main
+if __name__ == '__main__':
+    sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
+    sys.exit(main())

+ 1 - 0
venv/bin/python

@@ -0,0 +1 @@
+python3

+ 1 - 0
venv/bin/python3

@@ -0,0 +1 @@
+/usr/bin/python3

+ 1 - 0
venv/bin/python3.12

@@ -0,0 +1 @@
+python3

+ 8 - 0
venv/bin/sqlformat

@@ -0,0 +1,8 @@
+#!/home/creylopez/RegBrowser/regbrowser/venv/bin/python3
+# -*- coding: utf-8 -*-
+import re
+import sys
+from sqlparse.__main__ import main
+if __name__ == '__main__':
+    sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
+    sys.exit(main())

+ 1 - 0
venv/lib/python3.12/site-packages/asgiref-3.12.1.dist-info/INSTALLER

@@ -0,0 +1 @@
+pip

+ 249 - 0
venv/lib/python3.12/site-packages/asgiref-3.12.1.dist-info/METADATA

@@ -0,0 +1,249 @@
+Metadata-Version: 2.4
+Name: asgiref
+Version: 3.12.1
+Summary: ASGI specs, helper code, and adapters
+Home-page: https://github.com/django/asgiref/
+Author: Django Software Foundation
+Author-email: foundation@djangoproject.com
+License: BSD-3-Clause
+Project-URL: Documentation, https://asgi.readthedocs.io/
+Project-URL: Further Documentation, https://docs.djangoproject.com/en/stable/topics/async/#async-adapter-functions
+Project-URL: Changelog, https://github.com/django/asgiref/blob/master/CHANGELOG.txt
+Classifier: Development Status :: 5 - Production/Stable
+Classifier: Environment :: Web Environment
+Classifier: Intended Audience :: Developers
+Classifier: License :: OSI Approved :: BSD License
+Classifier: Operating System :: OS Independent
+Classifier: Programming Language :: Python
+Classifier: Programming Language :: Python :: 3
+Classifier: Programming Language :: Python :: 3 :: Only
+Classifier: Programming Language :: Python :: 3.10
+Classifier: Programming Language :: Python :: 3.11
+Classifier: Programming Language :: Python :: 3.12
+Classifier: Programming Language :: Python :: 3.13
+Classifier: Programming Language :: Python :: 3.14
+Classifier: Programming Language :: Python :: Free Threading :: 2 - Beta
+Classifier: Topic :: Internet :: WWW/HTTP
+Requires-Python: >=3.10
+License-File: LICENSE
+Requires-Dist: typing_extensions>=4; python_version < "3.11"
+Provides-Extra: tests
+Requires-Dist: pytest; extra == "tests"
+Requires-Dist: pytest-asyncio; extra == "tests"
+Provides-Extra: mypy
+Requires-Dist: mypy>=1.14.0; extra == "mypy"
+Dynamic: license-file
+
+asgiref
+=======
+
+.. image:: https://github.com/django/asgiref/actions/workflows/tests.yml/badge.svg
+    :target: https://github.com/django/asgiref/actions/workflows/tests.yml
+
+.. image:: https://img.shields.io/pypi/v/asgiref.svg
+    :target: https://pypi.python.org/pypi/asgiref
+
+ASGI is a standard for Python asynchronous web apps and servers to communicate
+with each other, and positioned as an asynchronous successor to WSGI. You can
+read more at https://asgi.readthedocs.io/en/latest/
+
+This package includes ASGI base libraries, such as:
+
+* Sync-to-async and async-to-sync function wrappers, ``asgiref.sync``
+* Server base classes, ``asgiref.server``
+* A WSGI-to-ASGI adapter, in ``asgiref.wsgi``
+
+
+Function wrappers
+-----------------
+
+These allow you to wrap or decorate async or sync functions to call them from
+the other style (so you can call async functions from a synchronous thread,
+or vice-versa).
+
+In particular:
+
+* AsyncToSync lets a synchronous subthread stop and wait while the async
+  function is called on the main thread's event loop, and then control is
+  returned to the thread when the async function is finished.
+
+* SyncToAsync lets async code call a synchronous function, which is run in
+  a threadpool and control returned to the async coroutine when the synchronous
+  function completes.
+
+The idea is to make it easier to call synchronous APIs from async code and
+asynchronous APIs from synchronous code so it's easier to transition code from
+one style to the other. In the case of Channels, we wrap the (synchronous)
+Django view system with SyncToAsync to allow it to run inside the (asynchronous)
+ASGI server.
+
+Note that exactly what threads things run in is very specific, and aimed to
+keep maximum compatibility with old synchronous code. See
+"Synchronous code & Threads" below for a full explanation. By default,
+``sync_to_async`` will run all synchronous code in the program in the same
+thread for safety reasons; you can disable this for more performance with
+``@sync_to_async(thread_sensitive=False)``, but make sure that your code does
+not rely on anything bound to threads (like database connections) when you do.
+
+
+Threadlocal replacement
+-----------------------
+
+This is a drop-in replacement for ``threading.local`` that works with both
+threads and asyncio Tasks. Even better, it will proxy values through from a
+task-local context to a thread-local context when you use ``sync_to_async``
+to run things in a threadpool, and vice-versa for ``async_to_sync``.
+
+If you instead want true thread- and task-safety, you can set
+``thread_critical`` on the Local object to ensure this instead.
+
+
+Server base classes
+-------------------
+
+Includes a ``StatelessServer`` class which provides all the hard work of
+writing a stateless server (as in, does not handle direct incoming sockets
+but instead consumes external streams or sockets to work out what is happening).
+
+An example of such a server would be a chatbot server that connects out to
+a central chat server and provides a "connection scope" per user chatting to
+it. There's only one actual connection, but the server has to separate things
+into several scopes for easier writing of the code.
+
+You can see an example of this being used in `frequensgi <https://github.com/andrewgodwin/frequensgi>`_.
+
+
+WSGI-to-ASGI adapter
+--------------------
+
+Allows you to wrap a WSGI application so it appears as a valid ASGI application.
+
+Simply wrap it around your WSGI application like so::
+
+    asgi_application = WsgiToAsgi(wsgi_application)
+
+The WSGI application will be run in a synchronous threadpool, and the wrapped
+ASGI application will be one that accepts ``http`` class messages.
+
+Please note that not all extended features of WSGI may be supported (such as
+file handles for incoming POST bodies).
+
+
+Dependencies
+------------
+
+``asgiref`` requires Python 3.10 or higher.
+
+
+Contributing
+------------
+
+Please refer to the
+`main Channels contributing docs <https://github.com/django/channels/blob/master/CONTRIBUTING.rst>`_.
+
+
+Testing
+'''''''
+
+To run tests, make sure you have installed the ``tests`` extra with the package::
+
+    cd asgiref/
+    pip install -e .[tests]
+    pytest
+
+
+Building the documentation
+''''''''''''''''''''''''''
+
+The documentation uses `Sphinx <http://www.sphinx-doc.org>`_::
+
+    cd asgiref/docs/
+    pip install sphinx
+
+To build the docs, you can use the default tools::
+
+    sphinx-build -b html . _build/html  # or `make html`, if you've got make set up
+    cd _build/html
+    python -m http.server
+
+...or you can use ``sphinx-autobuild`` to run a server and rebuild/reload
+your documentation changes automatically::
+
+    pip install sphinx-autobuild
+    sphinx-autobuild . _build/html
+
+
+Releasing
+'''''''''
+
+To release, first add details to CHANGELOG.txt and update the version number in ``asgiref/__init__.py``.
+
+Then, build and push the packages::
+
+    python -m build
+    twine upload dist/*
+    rm -r asgiref.egg-info dist
+
+
+Implementation Details
+----------------------
+
+Synchronous code & threads
+''''''''''''''''''''''''''
+
+The ``asgiref.sync`` module provides two wrappers that let you go between
+asynchronous and synchronous code at will, while taking care of the rough edges
+for you.
+
+Unfortunately, the rough edges are numerous, and the code has to work especially
+hard to keep things in the same thread as much as possible. Notably, the
+restrictions we are working with are:
+
+* All synchronous code called through ``SyncToAsync`` and marked with
+  ``thread_sensitive`` should run in the same thread as each other (and if the
+  outer layer of the program is synchronous, the main thread)
+
+* If a thread already has a running async loop, ``AsyncToSync`` can't run things
+  on that loop if it's blocked on synchronous code that is above you in the
+  call stack.
+
+The first compromise you get to might be that ``thread_sensitive`` code should
+just run in the same thread and not spawn in a sub-thread, fulfilling the first
+restriction, but that immediately runs you into the second restriction.
+
+The only real solution is to essentially have a variant of ThreadPoolExecutor
+that executes any ``thread_sensitive`` code on the outermost synchronous
+thread - either the main thread, or a single spawned subthread.
+
+This means you now have two basic states:
+
+* If the outermost layer of your program is synchronous, then all async code
+  run through ``AsyncToSync`` will run in a per-call event loop in arbitrary
+  sub-threads, while all ``thread_sensitive`` code will run in the main thread.
+
+* If the outermost layer of your program is asynchronous, then all async code
+  runs on the main thread's event loop, and all ``thread_sensitive`` synchronous
+  code will run in a single shared sub-thread.
+
+Crucially, this means that in both cases there is a thread which is a shared
+resource that all ``thread_sensitive`` code must run on, and there is a chance
+that this thread is currently blocked on its own ``AsyncToSync`` call. Thus,
+``AsyncToSync`` needs to act as an executor for thread code while it's blocking.
+
+The ``CurrentThreadExecutor`` class provides this functionality; rather than
+simply waiting on a Future, you can call its ``run_until_future`` method and
+it will run submitted code until that Future is done. This means that code
+inside the call can then run code on your thread.
+
+
+Maintenance and Security
+------------------------
+
+To report security issues, please contact security@djangoproject.com. For GPG
+signatures and more security process information, see
+https://docs.djangoproject.com/en/dev/internals/security/.
+
+To report bugs or request new features, please open a new GitHub issue.
+
+This repository is part of the Channels project. For the shepherd and maintenance team, please see the
+`main Channels readme <https://github.com/django/channels/blob/master/README.rst>`_.

+ 27 - 0
venv/lib/python3.12/site-packages/asgiref-3.12.1.dist-info/RECORD

@@ -0,0 +1,27 @@
+asgiref-3.12.1.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
+asgiref-3.12.1.dist-info/METADATA,sha256=LRAowPq6DzkafCP6uWBmHJsDOH7xoNcOBLgg3bO_opQ,9383
+asgiref-3.12.1.dist-info/RECORD,,
+asgiref-3.12.1.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
+asgiref-3.12.1.dist-info/licenses/LICENSE,sha256=uEZBXRtRTpwd_xSiLeuQbXlLxUbKYSn5UKGM0JHipmk,1552
+asgiref-3.12.1.dist-info/top_level.txt,sha256=bokQjCzwwERhdBiPdvYEZa4cHxT4NCeAffQNUqJ8ssg,8
+asgiref/__init__.py,sha256=4J9-DjMOtzdA7CSvdVspmfGUmFqJuTTG2pEaePRQA3I,23
+asgiref/__pycache__/__init__.cpython-312.pyc,,
+asgiref/__pycache__/compatibility.cpython-312.pyc,,
+asgiref/__pycache__/current_thread_executor.cpython-312.pyc,,
+asgiref/__pycache__/local.cpython-312.pyc,,
+asgiref/__pycache__/server.cpython-312.pyc,,
+asgiref/__pycache__/sync.cpython-312.pyc,,
+asgiref/__pycache__/testing.cpython-312.pyc,,
+asgiref/__pycache__/timeout.cpython-312.pyc,,
+asgiref/__pycache__/typing.cpython-312.pyc,,
+asgiref/__pycache__/wsgi.cpython-312.pyc,,
+asgiref/compatibility.py,sha256=DhY1SOpOvOw0Y1lSEjCqg-znRUQKecG3LTaV48MZi68,1606
+asgiref/current_thread_executor.py,sha256=iy9ui5vcbLge4YGFDyycrSsHR6x2idZQbtTvaIvagjQ,4068
+asgiref/local.py,sha256=S3SKnTQpR4nwhxJulPKbsB7urIQofH3iRUcYQ6ogUMQ,6568
+asgiref/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
+asgiref/server.py,sha256=UIYaF7z5ee8CaXlQ04gq6bUwaRKxW3LFIuuys-_AQQ8,6247
+asgiref/sync.py,sha256=U0Cl4BCRHtj2IsUu8FRRfct8GnFrP1tjUOPB-NBL9Bk,24206
+asgiref/testing.py,sha256=U5wcs_-ZYTO5SIGfl80EqRAGv_T8BHrAhvAKRuuztT4,4421
+asgiref/timeout.py,sha256=_d2F1WWjaoXjHYkCF3OH1gT6q4LJW3NCxRXyV35qqB0,3617
+asgiref/typing.py,sha256=Xuta94Om6mvPMULSAcsY13inxvyG__4WjS2vj9mWMEI,6185
+asgiref/wsgi.py,sha256=OSxanm5Qf-VfrOkAx62mYW0mOnh8pwaTz1099HbJzM0,7941

+ 5 - 0
venv/lib/python3.12/site-packages/asgiref-3.12.1.dist-info/WHEEL

@@ -0,0 +1,5 @@
+Wheel-Version: 1.0
+Generator: setuptools (83.0.0)
+Root-Is-Purelib: true
+Tag: py3-none-any
+

+ 27 - 0
venv/lib/python3.12/site-packages/asgiref-3.12.1.dist-info/licenses/LICENSE

@@ -0,0 +1,27 @@
+Copyright (c) Django Software Foundation and individual contributors.
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without modification,
+are permitted provided that the following conditions are met:
+
+    1. Redistributions of source code must retain the above copyright notice,
+       this list of conditions and the following disclaimer.
+
+    2. Redistributions in binary form must reproduce the above copyright
+       notice, this list of conditions and the following disclaimer in the
+       documentation and/or other materials provided with the distribution.
+
+    3. Neither the name of Django nor the names of its contributors may be used
+       to endorse or promote products derived from this software without
+       specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
+ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

+ 1 - 0
venv/lib/python3.12/site-packages/asgiref-3.12.1.dist-info/top_level.txt

@@ -0,0 +1 @@
+asgiref

+ 1 - 0
venv/lib/python3.12/site-packages/asgiref/__init__.py

@@ -0,0 +1 @@
+__version__ = "3.12.1"

二進制
venv/lib/python3.12/site-packages/asgiref/__pycache__/__init__.cpython-312.pyc


二進制
venv/lib/python3.12/site-packages/asgiref/__pycache__/compatibility.cpython-312.pyc


二進制
venv/lib/python3.12/site-packages/asgiref/__pycache__/current_thread_executor.cpython-312.pyc


二進制
venv/lib/python3.12/site-packages/asgiref/__pycache__/local.cpython-312.pyc


二進制
venv/lib/python3.12/site-packages/asgiref/__pycache__/server.cpython-312.pyc


二進制
venv/lib/python3.12/site-packages/asgiref/__pycache__/sync.cpython-312.pyc


二進制
venv/lib/python3.12/site-packages/asgiref/__pycache__/testing.cpython-312.pyc


二進制
venv/lib/python3.12/site-packages/asgiref/__pycache__/timeout.cpython-312.pyc


二進制
venv/lib/python3.12/site-packages/asgiref/__pycache__/typing.cpython-312.pyc


二進制
venv/lib/python3.12/site-packages/asgiref/__pycache__/wsgi.cpython-312.pyc


+ 48 - 0
venv/lib/python3.12/site-packages/asgiref/compatibility.py

@@ -0,0 +1,48 @@
+import inspect
+
+from .sync import iscoroutinefunction
+
+
+def is_double_callable(application):
+    """
+    Tests to see if an application is a legacy-style (double-callable) application.
+    """
+    # Look for a hint on the object first
+    if getattr(application, "_asgi_single_callable", False):
+        return False
+    if getattr(application, "_asgi_double_callable", False):
+        return True
+    # Uninstanted classes are double-callable
+    if inspect.isclass(application):
+        return True
+    # Instanted classes depend on their __call__
+    if hasattr(application, "__call__"):
+        # We only check to see if its __call__ is a coroutine function -
+        # if it's not, it still might be a coroutine function itself.
+        if iscoroutinefunction(application.__call__):
+            return False
+    # Non-classes we just check directly
+    return not iscoroutinefunction(application)
+
+
+def double_to_single_callable(application):
+    """
+    Transforms a double-callable ASGI application into a single-callable one.
+    """
+
+    async def new_application(scope, receive, send):
+        instance = application(scope)
+        return await instance(receive, send)
+
+    return new_application
+
+
+def guarantee_single_callable(application):
+    """
+    Takes either a single- or double-callable application and always returns it
+    in single-callable style. Use this to add backwards compatibility for ASGI
+    2.0 applications to your server/test harness/etc.
+    """
+    if is_double_callable(application):
+        application = double_to_single_callable(application)
+    return application

+ 118 - 0
venv/lib/python3.12/site-packages/asgiref/current_thread_executor.py

@@ -0,0 +1,118 @@
+import threading
+from collections import deque
+from collections.abc import Callable
+from concurrent.futures import Executor, Future
+from typing import Any, ParamSpec, TypeVar
+
+_T = TypeVar("_T")
+_P = ParamSpec("_P")
+_R = TypeVar("_R")
+
+
+class _WorkItem:
+    """
+    Represents an item needing to be run in the executor.
+    Copied from ThreadPoolExecutor (but it's private, so we're not going to rely on importing it)
+    """
+
+    def __init__(
+        self,
+        future: "Future[_R]",
+        fn: Callable[_P, _R],
+        *args: _P.args,
+        **kwargs: _P.kwargs,
+    ):
+        self.future = future
+        self.fn = fn
+        self.args = args
+        self.kwargs = kwargs
+
+    def run(self) -> None:
+        __traceback_hide__ = True  # noqa: F841
+        if not self.future.set_running_or_notify_cancel():
+            return
+        try:
+            result = self.fn(*self.args, **self.kwargs)
+        except BaseException as exc:
+            self.future.set_exception(exc)
+            # Break a reference cycle with the exception 'exc'
+            self = None  # type: ignore[assignment]
+        else:
+            self.future.set_result(result)
+
+
+class CurrentThreadExecutor(Executor):
+    """
+    An Executor that actually runs code in the thread it is instantiated in.
+    Passed to other threads running async code, so they can run sync code in
+    the thread they came from.
+    """
+
+    def __init__(self, old_executor: "CurrentThreadExecutor | None") -> None:
+        self._work_thread = threading.current_thread()
+        self._work_ready = threading.Condition(threading.Lock())
+        self._work_items = deque[_WorkItem]()  # synchronized by _work_ready
+        self._broken = False  # synchronized by _work_ready
+        self._old_executor = old_executor
+
+    def run_until_future(self, future: "Future[Any]") -> None:
+        """
+        Runs the code in the work queue until a result is available from the future.
+        Should be run from the thread the executor is initialised in.
+        """
+        # Check we're in the right thread
+        if threading.current_thread() != self._work_thread:
+            raise RuntimeError(
+                "You cannot run CurrentThreadExecutor from a different thread"
+            )
+
+        def done(future: "Future[Any]") -> None:
+            with self._work_ready:
+                self._broken = True
+                self._work_ready.notify()
+
+        future.add_done_callback(done)
+        # Keep getting and running work items until the future we're waiting for
+        # is done and the queue is empty.
+        while True:
+            with self._work_ready:
+                while not self._work_items and not self._broken:
+                    self._work_ready.wait()
+                if not self._work_items:
+                    break
+                # Get a work item and run it
+                work_item = self._work_items.popleft()
+            work_item.run()
+            del work_item
+
+    def submit(
+        self,
+        fn: Callable[_P, _R],
+        /,
+        *args: _P.args,
+        **kwargs: _P.kwargs,
+    ) -> "Future[_R]":
+        # Check they're not submitting from the same thread
+        if threading.current_thread() == self._work_thread:
+            raise RuntimeError(
+                "You cannot submit onto CurrentThreadExecutor from its own thread"
+            )
+        f: "Future[_R]" = Future()
+        work_item = _WorkItem(f, fn, *args, **kwargs)
+
+        # Walk up the CurrentThreadExecutor stack to find the closest one still
+        # running
+        executor = self
+        while True:
+            with executor._work_ready:
+                if not executor._broken:
+                    # Add to work queue
+                    executor._work_items.append(work_item)
+                    executor._work_ready.notify()
+                    break
+            if executor._old_executor is None:
+                raise RuntimeError("CurrentThreadExecutor already quit or is broken")
+            executor = executor._old_executor
+
+        # Return the future
+        return f

+ 168 - 0
venv/lib/python3.12/site-packages/asgiref/local.py

@@ -0,0 +1,168 @@
+import asyncio
+import contextlib
+import contextvars
+import threading
+from typing import Any, Union
+
+
+class _Storage:
+    """Thread-tagged storage for a non-thread-critical ``Local``.
+
+    The data is tagged with the identity of the thread that owns it. This lets
+    ``_CVar`` ignore data that leaked into an unrelated thread.
+
+    Python 3.14 added ``sys.flags.thread_inherit_context``, which is enabled by
+    default on free-threaded builds. When set, a new thread starts with a copy
+    of the spawning thread's context instead of an empty one, so the contextvar
+    backing a ``Local`` would otherwise be visible in any thread spawned from
+    one that had set it -- breaking the documented "thread-local in sync
+    threads" behaviour. asgiref re-homes the storage to the current thread at
+    the points where it *intentionally* moves work between threads (see
+    ``asgiref.sync._restore_context``); data merely inherited by an unrelated
+    thread is never re-homed and so stays isolated.
+    """
+
+    __slots__ = ("thread_id", "data")
+
+    def __init__(self, thread_id: int, data: dict[str, Any]) -> None:
+        self.thread_id = thread_id
+        self.data = data
+
+
+def _rehome(storage: "_Storage") -> "_Storage":
+    """Return a copy of *storage* owned by the current thread."""
+    return _Storage(threading.get_ident(), storage.data)
+
+
+class _CVar:
+    """Storage utility for Local."""
+
+    def __init__(self) -> None:
+        self._data: "contextvars.ContextVar[_Storage]" = contextvars.ContextVar(
+            "asgiref.local"
+        )
+
+    def _storage(self) -> "_Storage":
+        # Only return storage that belongs to the current thread. Storage with
+        # a different thread id was inherited by this thread (rather than
+        # intentionally moved here by asgiref) and must not be visible.
+        storage = self._data.get(None)
+        if storage is None or storage.thread_id != threading.get_ident():
+            return _Storage(threading.get_ident(), {})
+        return storage
+
+    def __getattr__(self, key):
+        try:
+            return self._storage().data[key]
+        except KeyError:
+            raise AttributeError(f"{self!r} object has no attribute {key!r}")
+
+    def __setattr__(self, key: str, value: Any) -> None:
+        if key == "_data":
+            return super().__setattr__(key, value)
+
+        data = self._storage().data.copy()
+        data[key] = value
+        self._data.set(_Storage(threading.get_ident(), data))
+
+    def __delattr__(self, key: str) -> None:
+        data = self._storage().data.copy()
+        if key in data:
+            del data[key]
+            self._data.set(_Storage(threading.get_ident(), data))
+        else:
+            raise AttributeError(f"{self!r} object has no attribute {key!r}")
+
+
+class Local:
+    """Local storage for async tasks.
+
+    This is a namespace object (similar to `threading.local`) where data is
+    also local to the current async task (if there is one).
+
+    In async threads, local means in the same sense as the `contextvars`
+    module - i.e. a value set in an async frame will be visible:
+
+    - to other async code `await`-ed from this frame.
+    - to tasks spawned using `asyncio` utilities (`create_task`, `wait_for`,
+      `gather` and probably others).
+    - to code scheduled in a sync thread using `sync_to_async`
+
+    In "sync" threads (a thread with no async event loop running), the
+    data is thread-local, but additionally shared with async code executed
+    via the `async_to_sync` utility, which schedules async code in a new thread
+    and copies context across to that thread.
+
+    If `thread_critical` is True, then the local will only be visible per-thread,
+    behaving exactly like `threading.local` if the thread is sync, and as
+    `contextvars` if the thread is async. This allows genuinely thread-sensitive
+    code (such as DB handles) to be kept strictly to their initial thread and
+    disable the sharing across `sync_to_async` and `async_to_sync` wrapped calls.
+
+    Unlike plain `contextvars` objects, this utility is threadsafe.
+    """
+
+    def __init__(self, thread_critical: bool = False) -> None:
+        self._thread_critical = thread_critical
+        self._thread_lock = threading.RLock()
+
+        self._storage: "Union[threading.local, _CVar]"
+
+        if thread_critical:
+            # Thread-local storage
+            self._storage = threading.local()
+        else:
+            # Contextvar storage
+            self._storage = _CVar()
+
+    @contextlib.contextmanager
+    def _lock_storage(self):
+        # Thread safe access to storage
+        if self._thread_critical:
+            is_async = True
+            try:
+                # this is a test for are we in a async or sync
+                # thread - will raise RuntimeError if there is
+                # no current loop
+                asyncio.get_running_loop()
+            except RuntimeError:
+                is_async = False
+            if not is_async:
+                # We are in a sync thread, the storage is
+                # just the plain thread local (i.e, "global within
+                # this thread" - it doesn't matter where you are
+                # in a call stack you see the same storage)
+                yield self._storage
+            else:
+                # We are in an async thread - storage is still
+                # local to this thread, but additionally should
+                # behave like a context var (is only visible with
+                # the same async call stack)
+
+                # Ensure context exists in the current thread
+                if not hasattr(self._storage, "cvar"):
+                    self._storage.cvar = _CVar()
+
+                # self._storage is a thread local, so the members
+                # can't be accessed in another thread (we don't
+                # need any locks)
+                yield self._storage.cvar
+        else:
+            # Lock for thread_critical=False as other threads
+            # can access the exact same storage object
+            with self._thread_lock:
+                yield self._storage
+
+    def __getattr__(self, key):
+        with self._lock_storage() as storage:
+            return getattr(storage, key)
+
+    def __setattr__(self, key, value):
+        if key in ("_local", "_storage", "_thread_critical", "_thread_lock"):
+            return super().__setattr__(key, value)
+        with self._lock_storage() as storage:
+            setattr(storage, key, value)
+
+    def __delattr__(self, key):
+        with self._lock_storage() as storage:
+            delattr(storage, key)

+ 0 - 0
venv/lib/python3.12/site-packages/asgiref/py.typed


+ 172 - 0
venv/lib/python3.12/site-packages/asgiref/server.py

@@ -0,0 +1,172 @@
+import asyncio
+import logging
+import time
+import traceback
+
+from .compatibility import guarantee_single_callable
+
+logger = logging.getLogger(__name__)
+
+
+class StatelessServer:
+    """
+    Base server class that handles basic concepts like application instance
+    creation/pooling, exception handling, and similar, for stateless protocols
+    (i.e. ones without actual incoming connections to the process)
+
+    Your code should override the handle() method, doing whatever it needs to,
+    and calling get_or_create_application_instance with a unique `scope_id`
+    and `scope` for the scope it wants to get.
+
+    If an application instance is found with the same `scope_id`, you are
+    given its input queue, otherwise one is made for you with the scope provided
+    and you are given that fresh new input queue. Either way, you should do
+    something like:
+
+    input_queue = self.get_or_create_application_instance(
+        "user-123456",
+        {"type": "testprotocol", "user_id": "123456", "username": "andrew"},
+    )
+    input_queue.put_nowait(message)
+
+    If you try and create an application instance and there are already
+    `max_application` instances, the oldest/least recently used one will be
+    reclaimed and shut down to make space.
+
+    Application coroutines that error will be found periodically (every 100ms
+    by default) and have their exceptions printed to the console. Override
+    application_exception() if you want to do more when this happens.
+
+    If you override run(), make sure you handle things like launching the
+    application checker.
+    """
+
+    application_checker_interval = 0.1
+
+    def __init__(self, application, max_applications=1000):
+        # Parameters
+        self.application = application
+        self.max_applications = max_applications
+        # Initialisation
+        self.application_instances = {}
+
+    ### Mainloop and handling
+
+    def run(self):
+        """
+        Runs the asyncio event loop with our handler loop.
+        """
+        try:
+            asyncio.run(self.arun())
+        except KeyboardInterrupt:
+            logger.info("Exiting due to Ctrl-C/interrupt")
+
+    async def arun(self):
+        """
+        Runs the asyncio event loop with our handler loop.
+        """
+
+        class Done(Exception):
+            pass
+
+        async def handle():
+            await self.handle()
+            raise Done
+
+        try:
+            await asyncio.gather(self.application_checker(), handle())
+        except Done:
+            pass
+
+    async def handle(self):
+        raise NotImplementedError("You must implement handle()")
+
+    async def application_send(self, scope, message):
+        """
+        Receives outbound sends from applications and handles them.
+        """
+        raise NotImplementedError("You must implement application_send()")
+
+    ### Application instance management
+
+    def get_or_create_application_instance(self, scope_id, scope):
+        """
+        Creates an application instance and returns its queue.
+        """
+        if scope_id in self.application_instances:
+            self.application_instances[scope_id]["last_used"] = time.time()
+            return self.application_instances[scope_id]["input_queue"]
+        # See if we need to delete an old one
+        while len(self.application_instances) > self.max_applications:
+            self.delete_oldest_application_instance()
+        # Make an instance of the application
+        input_queue = asyncio.Queue()
+        application_instance = guarantee_single_callable(self.application)
+        # Run it, and stash the future for later checking
+        future = asyncio.ensure_future(
+            application_instance(
+                scope=scope,
+                receive=input_queue.get,
+                send=lambda message: self.application_send(scope, message),
+            ),
+        )
+        self.application_instances[scope_id] = {
+            "input_queue": input_queue,
+            "future": future,
+            "scope": scope,
+            "last_used": time.time(),
+        }
+        return input_queue
+
+    def delete_oldest_application_instance(self):
+        """
+        Finds and deletes the oldest application instance
+        """
+        oldest_time = min(
+            details["last_used"] for details in self.application_instances.values()
+        )
+        for scope_id, details in self.application_instances.items():
+            if details["last_used"] == oldest_time:
+                self.delete_application_instance(scope_id)
+                # Return to make sure we only delete one in case two have
+                # the same oldest time
+                return
+
+    def delete_application_instance(self, scope_id):
+        """
+        Removes an application instance (makes sure its task is stopped,
+        then removes it from the current set)
+        """
+        details = self.application_instances[scope_id]
+        del self.application_instances[scope_id]
+        if not details["future"].done():
+            details["future"].cancel()
+
+    async def application_checker(self):
+        """
+        Goes through the set of current application instance Futures and cleans up
+        any that are done/prints exceptions for any that errored.
+        """
+        while True:
+            await asyncio.sleep(self.application_checker_interval)
+            for scope_id, details in list(self.application_instances.items()):
+                if details["future"].done():
+                    exception = details["future"].exception()
+                    if exception:
+                        await self.application_exception(exception, details)
+                    try:
+                        del self.application_instances[scope_id]
+                    except KeyError:
+                        # Exception handling might have already got here before us. That's fine.
+                        pass
+
+    async def application_exception(self, exception, application_details):
+        """
+        Called whenever an application coroutine has an exception.
+        """
+        logging.error(
+            "Exception inside application: %s\n%s%s",
+            exception,
+            "".join(traceback.format_tb(exception.__traceback__)),
+            f"  {exception}",
+        )

+ 667 - 0
venv/lib/python3.12/site-packages/asgiref/sync.py

@@ -0,0 +1,667 @@
+import asyncio
+import asyncio.coroutines
+import contextvars
+import functools
+import inspect
+import os
+import sys
+import threading
+import warnings
+import weakref
+from collections.abc import Awaitable, Callable, Coroutine
+from concurrent.futures import Future, InvalidStateError, ThreadPoolExecutor
+from typing import (
+    TYPE_CHECKING,
+    Any,
+    Dict,
+    Generic,
+    List,
+    Optional,
+    ParamSpec,
+    TypeVar,
+    overload,
+)
+
+from .current_thread_executor import CurrentThreadExecutor
+from .local import Local, _rehome, _Storage
+
+if TYPE_CHECKING:
+    # This is not available to import at runtime
+    from _typeshed import OptExcInfo
+
+_F = TypeVar("_F", bound=Callable[..., Any])
+_P = ParamSpec("_P")
+_R = TypeVar("_R")
+
+
+def _restore_context(context: contextvars.Context) -> None:
+    # Check for changes in contextvars, and set them to the current
+    # context for downstream consumers
+    for cvar in context:
+        cvalue = context.get(cvar)
+        # asgiref is deliberately moving this context onto the current thread,
+        # so re-home any Local storage to it. This keeps Local data visible
+        # across async_to_sync / sync_to_async boundaries while leaving data
+        # merely inherited by an unrelated thread isolated (see asgiref.local).
+        if isinstance(cvalue, _Storage):
+            cvalue = _rehome(cvalue)
+        try:
+            if cvar.get() != cvalue:
+                cvar.set(cvalue)
+        except LookupError:
+            cvar.set(cvalue)
+
+
+# Python 3.12 deprecates asyncio.iscoroutinefunction() as an alias for
+# inspect.iscoroutinefunction(), whilst also removing the _is_coroutine marker.
+# The latter is replaced with the inspect.markcoroutinefunction decorator.
+# Until 3.12 is the minimum supported Python version, provide a shim.
+
+if hasattr(inspect, "markcoroutinefunction"):
+    iscoroutinefunction = inspect.iscoroutinefunction
+    markcoroutinefunction: Callable[[_F], _F] = inspect.markcoroutinefunction
+else:
+    iscoroutinefunction = asyncio.iscoroutinefunction  # type: ignore[assignment]
+
+    def markcoroutinefunction(func: _F) -> _F:
+        func._is_coroutine = asyncio.coroutines._is_coroutine  # type: ignore
+        return func
+
+
+class AsyncSingleThreadContext:
+    """Context manager to run async code inside the same thread.
+
+    Normally, AsyncToSync functions run either inside a separate ThreadPoolExecutor or
+    the main event loop if it exists. This context manager ensures that all AsyncToSync
+    functions execute within the same thread.
+
+    This context manager is re-entrant, so only the outer-most call to
+    AsyncSingleThreadContext will set the context.
+
+    Usage:
+
+    >>> import asyncio
+    >>> with AsyncSingleThreadContext():
+    ...     async_to_sync(asyncio.sleep(1))()
+    """
+
+    def __init__(self):
+        self.token = None
+
+    def __enter__(self):
+        try:
+            AsyncToSync.async_single_thread_context.get()
+        except LookupError:
+            self.token = AsyncToSync.async_single_thread_context.set(self)
+
+        return self
+
+    def __exit__(self, exc, value, tb):
+        if not self.token:
+            return
+
+        executor = AsyncToSync.context_to_thread_executor.pop(self, None)
+        if executor:
+            executor.shutdown()
+
+        AsyncToSync.async_single_thread_context.reset(self.token)
+
+
+class ThreadSensitiveContext:
+    """Async context manager to manage context for thread sensitive mode
+
+    This context manager controls which thread pool executor is used when in
+    thread sensitive mode. By default, a single thread pool executor is shared
+    within a process.
+
+    The ThreadSensitiveContext() context manager may be used to specify a
+    thread pool per context.
+
+    This context manager is re-entrant, so only the outer-most call to
+    ThreadSensitiveContext will set the context.
+
+    Usage:
+
+    >>> import time
+    >>> async with ThreadSensitiveContext():
+    ...     await sync_to_async(time.sleep, 1)()
+    """
+
+    def __init__(self):
+        self.token = None
+
+    async def __aenter__(self):
+        try:
+            SyncToAsync.thread_sensitive_context.get()
+        except LookupError:
+            self.token = SyncToAsync.thread_sensitive_context.set(self)
+
+        return self
+
+    async def __aexit__(self, exc, value, tb):
+        if not self.token:
+            return
+
+        executor = SyncToAsync.context_to_thread_executor.pop(self, None)
+        SyncToAsync.thread_sensitive_context.reset(self.token)
+        if executor:
+            # The executor's worker thread may itself be waiting for this
+            # event loop, so a blocking shutdown() here would deadlock it.
+            # Join in a dedicated thread, not the loop's default executor:
+            # work queued there may itself be needed to unpark the worker,
+            # and joins occupying its slots would starve it.
+            future: "Future[None]" = Future()
+
+            def join() -> None:
+                executor.shutdown()
+                try:
+                    future.set_result(None)
+                except InvalidStateError:
+                    # The await below was cancelled while we were joining.
+                    pass
+
+            threading.Thread(target=join, daemon=True).start()
+            await asyncio.wrap_future(future)
+
+
+class AsyncToSync(Generic[_P, _R]):
+    """
+    Utility class which turns an awaitable that only works on the thread with
+    the event loop into a synchronous callable that works in a subthread.
+
+    If the call stack contains an async loop, the code runs there.
+    Otherwise, the code runs in a new loop in a new thread.
+
+    Either way, this thread then pauses and waits to run any thread_sensitive
+    code called from further down the call stack using SyncToAsync, before
+    finally exiting once the async task returns.
+    """
+
+    # Keeps a reference to the CurrentThreadExecutor in local context, so that
+    # any sync_to_async inside the wrapped code can find it.
+    executors: "Local" = Local()
+
+    # When we can't find a CurrentThreadExecutor from the context, such as
+    # inside create_task, we'll look it up here from the running event loop.
+    loop_thread_executors: "Dict[asyncio.AbstractEventLoop, CurrentThreadExecutor]" = {}
+
+    async_single_thread_context: "contextvars.ContextVar[AsyncSingleThreadContext]" = (
+        contextvars.ContextVar("async_single_thread_context")
+    )
+
+    context_to_thread_executor: (
+        "weakref.WeakKeyDictionary[AsyncSingleThreadContext, ThreadPoolExecutor]"
+    ) = weakref.WeakKeyDictionary()
+
+    def __init__(
+        self,
+        awaitable: Callable[_P, Coroutine[Any, Any, _R]] | Callable[_P, Awaitable[_R]],
+        force_new_loop: bool = False,
+    ):
+        if not callable(awaitable) or (
+            not iscoroutinefunction(awaitable)
+            and not iscoroutinefunction(getattr(awaitable, "__call__", awaitable))
+        ):
+            # Python does not have very reliable detection of async functions
+            # (lots of false negatives) so this is just a warning.
+            warnings.warn(
+                "async_to_sync was passed a non-async-marked callable", stacklevel=2
+            )
+        self.awaitable = awaitable
+        try:
+            self.__self__ = self.awaitable.__self__  # type: ignore[union-attr]
+        except AttributeError:
+            pass
+        self.force_new_loop = force_new_loop
+
+    def __call__(self, *args: _P.args, **kwargs: _P.kwargs) -> _R:
+        __traceback_hide__ = True  # noqa: F841
+
+        main_event_loop = None
+        if not self.force_new_loop:
+            # There's no event loop in this thread. Look for the threadlocal if
+            # we're inside SyncToAsync
+            main_event_loop_pid = getattr(
+                SyncToAsync.threadlocal, "main_event_loop_pid", None
+            )
+            # We make sure the parent loop is from the same process - if
+            # they've forked, this is not going to be valid any more (#194)
+            if main_event_loop_pid and main_event_loop_pid == os.getpid():
+                main_event_loop = getattr(
+                    SyncToAsync.threadlocal, "main_event_loop", None
+                )
+
+        # You can't call AsyncToSync from a thread with a running event loop
+        try:
+            asyncio.get_running_loop()
+        except RuntimeError:
+            pass
+        else:
+            raise RuntimeError(
+                "You cannot use AsyncToSync in the same thread as an async event loop - "
+                "just await the async function directly."
+            )
+
+        # Make a future for the return information
+        call_result: "Future[_R]" = Future()
+
+        # Make a CurrentThreadExecutor we'll use to idle in this thread - we
+        # need one for every sync frame, even if there's one above us in the
+        # same thread.
+        old_executor = getattr(self.executors, "current", None)
+        current_executor = CurrentThreadExecutor(old_executor)
+        self.executors.current = current_executor
+
+        # Wrapping context in list so it can be reassigned from within
+        # `main_wrap`.
+        context = [contextvars.copy_context()]
+
+        # Get task context so that parent task knows which task to propagate
+        # an asyncio.CancelledError to.
+        task_context = getattr(SyncToAsync.threadlocal, "task_context", None)
+
+        # Use call_soon_threadsafe to schedule a synchronous callback on the
+        # main event loop's thread if it's there, otherwise make a new loop
+        # in this thread.
+        try:
+            awaitable = self.main_wrap(
+                call_result,
+                sys.exc_info(),
+                task_context,
+                context,
+                # prepare an awaitable which can be passed as is to self.main_wrap,
+                # so that `args` and `kwargs` don't need to be
+                # destructured when passed to self.main_wrap
+                # (which is required by `ParamSpec`)
+                # as that may cause overlapping arguments
+                self.awaitable(*args, **kwargs),
+            )
+
+            async def new_loop_wrap() -> None:
+                loop = asyncio.get_running_loop()
+                self.loop_thread_executors[loop] = current_executor
+                try:
+                    await awaitable
+                finally:
+                    del self.loop_thread_executors[loop]
+
+            if main_event_loop is not None:
+                try:
+                    main_event_loop.call_soon_threadsafe(
+                        main_event_loop.create_task, awaitable
+                    )
+                except RuntimeError:
+                    running_in_main_event_loop = False
+                else:
+                    running_in_main_event_loop = True
+                    # Run the CurrentThreadExecutor until the future is done.
+                    current_executor.run_until_future(call_result)
+            else:
+                running_in_main_event_loop = False
+
+            if not running_in_main_event_loop:
+                loop_executor = None
+
+                if self.async_single_thread_context.get(None):
+                    single_thread_context = self.async_single_thread_context.get()
+
+                    if single_thread_context in self.context_to_thread_executor:
+                        loop_executor = self.context_to_thread_executor[
+                            single_thread_context
+                        ]
+                    else:
+                        loop_executor = ThreadPoolExecutor(max_workers=1)
+                        self.context_to_thread_executor[single_thread_context] = (
+                            loop_executor
+                        )
+                else:
+                    # Make our own event loop - in a new thread - and run inside that.
+                    loop_executor = ThreadPoolExecutor(max_workers=1)
+
+                loop_future = loop_executor.submit(asyncio.run, new_loop_wrap())
+                # Run the CurrentThreadExecutor until the future is done.
+                current_executor.run_until_future(loop_future)
+                # Wait for future and/or allow for exception propagation
+                loop_future.result()
+        finally:
+            _restore_context(context[0])
+            # Restore old current thread executor state
+            self.executors.current = old_executor
+
+        # Wait for results from the future.
+        return call_result.result()
+
+    def __get__(self, parent: Any, objtype: Any) -> Callable[_P, _R]:
+        """
+        Include self for methods
+        """
+        func = functools.partial(self.__call__, parent)
+        return functools.update_wrapper(func, self.awaitable)
+
+    async def main_wrap(
+        self,
+        call_result: "Future[_R]",
+        exc_info: "OptExcInfo",
+        task_context: "Optional[List[asyncio.Task[Any]]]",
+        context: list[contextvars.Context],
+        awaitable: Coroutine[Any, Any, _R] | Awaitable[_R],
+    ) -> None:
+        """
+        Wraps the awaitable with something that puts the result into the
+        result/exception future.
+        """
+
+        __traceback_hide__ = True  # noqa: F841
+
+        if context is not None:
+            _restore_context(context[0])
+
+        current_task = asyncio.current_task()
+        if current_task is not None and task_context is not None:
+            task_context.append(current_task)
+
+        try:
+            # If we have an exception, run the function inside the except block
+            # after raising it so exc_info is correctly populated.
+            if exc_info[1]:
+                try:
+                    raise exc_info[1]
+                except BaseException:
+                    result = await awaitable
+            else:
+                result = await awaitable
+        except BaseException as e:
+            call_result.set_exception(e)
+        else:
+            call_result.set_result(result)
+        finally:
+            if current_task is not None and task_context is not None:
+                task_context.remove(current_task)
+            context[0] = contextvars.copy_context()
+
+
+class SyncToAsync(Generic[_P, _R]):
+    """
+    Utility class which turns a synchronous callable into an awaitable that
+    runs in a threadpool. It also sets a threadlocal inside the thread so
+    calls to AsyncToSync can escape it.
+
+    If thread_sensitive is passed, the code will run in the same thread as any
+    outer code. This is needed for underlying Python code that is not
+    threadsafe (for example, code which handles SQLite database connections).
+
+    If the outermost program is async (i.e. SyncToAsync is outermost), then
+    this will be a dedicated single sub-thread that all sync code runs in,
+    one after the other. If the outermost program is sync (i.e. AsyncToSync is
+    outermost), this will just be the main thread. This is achieved by idling
+    with a CurrentThreadExecutor while AsyncToSync is blocking its sync parent,
+    rather than just blocking.
+
+    If executor is passed in, that will be used instead of the loop's default executor.
+    In order to pass in an executor, thread_sensitive must be set to False, otherwise
+    a TypeError will be raised.
+    """
+
+    # Storage for main event loop references
+    threadlocal = threading.local()
+
+    # Single-thread executor for thread-sensitive code
+    single_thread_executor = ThreadPoolExecutor(max_workers=1)
+
+    # Maintain a contextvar for the current execution context. Optionally used
+    # for thread sensitive mode.
+    thread_sensitive_context: "contextvars.ContextVar[ThreadSensitiveContext]" = (
+        contextvars.ContextVar("thread_sensitive_context")
+    )
+
+    # Contextvar that is used to detect if the single thread executor
+    # would be awaited on while already being used in the same context
+    deadlock_context: "contextvars.ContextVar[bool]" = contextvars.ContextVar(
+        "deadlock_context"
+    )
+
+    # Maintaining a weak reference to the context ensures that thread pools are
+    # erased once the context goes out of scope. This terminates the thread pool.
+    context_to_thread_executor: (
+        "weakref.WeakKeyDictionary[ThreadSensitiveContext, ThreadPoolExecutor]"
+    ) = weakref.WeakKeyDictionary()
+
+    def __init__(
+        self,
+        func: Callable[_P, _R],
+        thread_sensitive: bool = True,
+        executor: Optional["ThreadPoolExecutor"] = None,
+        context: contextvars.Context | None = None,
+    ) -> None:
+        if (
+            not callable(func)
+            or iscoroutinefunction(func)
+            or iscoroutinefunction(getattr(func, "__call__", func))
+        ):
+            raise TypeError("sync_to_async can only be applied to sync functions.")
+
+        functools.update_wrapper(self, func)
+        self.func = func
+        self.context = context
+
+        self._thread_sensitive = thread_sensitive
+        markcoroutinefunction(self)
+        if thread_sensitive and executor is not None:
+            raise TypeError("executor must not be set when thread_sensitive is True")
+        self._executor = executor
+        try:
+            self.__self__ = func.__self__  # type: ignore
+        except AttributeError:
+            pass
+
+    async def __call__(self, *args: _P.args, **kwargs: _P.kwargs) -> _R:
+        __traceback_hide__ = True  # noqa: F841
+        loop = asyncio.get_running_loop()
+
+        # Work out what thread to run the code in
+        if self._thread_sensitive:
+            current_thread_executor = getattr(AsyncToSync.executors, "current", None)
+            if current_thread_executor:
+                # If we have a parent sync thread above somewhere, use that
+                executor = current_thread_executor
+            elif self.thread_sensitive_context.get(None):
+                # If we have a way of retrieving the current context, attempt
+                # to use a per-context thread pool executor
+                thread_sensitive_context = self.thread_sensitive_context.get()
+
+                if thread_sensitive_context in self.context_to_thread_executor:
+                    # Re-use thread executor in current context
+                    executor = self.context_to_thread_executor[thread_sensitive_context]
+                else:
+                    # Create new thread executor in current context
+                    executor = ThreadPoolExecutor(max_workers=1)
+                    self.context_to_thread_executor[thread_sensitive_context] = executor
+            elif loop in AsyncToSync.loop_thread_executors:
+                # Re-use thread executor for running loop
+                executor = AsyncToSync.loop_thread_executors[loop]
+            elif self.deadlock_context.get(False):
+                raise RuntimeError(
+                    "Single thread executor already being used, would deadlock"
+                )
+            else:
+                # Otherwise, we run it in a fixed single thread
+                executor = self.single_thread_executor
+                self.deadlock_context.set(True)
+        else:
+            # Use the passed in executor, or the loop's default if it is None
+            executor = self._executor
+
+        context = contextvars.copy_context() if self.context is None else self.context
+        # ``child`` is the deferred sync function to be run, with its args
+        # and kwargs bound.
+        child = functools.partial(self.func, *args, **kwargs)
+
+        # On the worker thread, thread_handler runs ``func(child)``. ``func``
+        # enters ``context`` (via context.run); then, inside it, ``run_child``
+        # re-homes any Local storage to the worker thread so it stays visible
+        # there (see _restore_context), and finally calls ``child``.
+        def func(child: Callable[[], _R]) -> _R:
+            def run_child() -> _R:
+                _restore_context(context)
+                return child()
+
+            return context.run(run_child)
+
+        task_context: list[asyncio.Task[Any]] = []
+
+        # Run the code in the right thread
+        exec_coro = loop.run_in_executor(
+            executor,
+            functools.partial(
+                self.thread_handler,
+                loop,
+                sys.exc_info(),
+                task_context,
+                func,
+                child,
+            ),
+        )
+        ret: _R
+        try:
+            ret = await asyncio.shield(exec_coro)
+        except asyncio.CancelledError:
+            cancel_parent = True
+            try:
+                task = task_context[0]
+                task.cancel()
+                try:
+                    await task
+                    cancel_parent = False
+                except asyncio.CancelledError:
+                    pass
+            except IndexError:
+                pass
+            if exec_coro.done():
+                raise
+            if cancel_parent:
+                exec_coro.cancel()
+            ret = await exec_coro
+        finally:
+            if self.context is None:
+                _restore_context(context)
+            self.deadlock_context.set(False)
+
+        return ret
+
+    def __get__(
+        self, parent: Any, objtype: Any
+    ) -> Callable[_P, Coroutine[Any, Any, _R]]:
+        """
+        Include self for methods
+        """
+        func = functools.partial(self.__call__, parent)
+        return functools.update_wrapper(func, self.func)
+
+    def thread_handler(self, loop, exc_info, task_context, func, *args, **kwargs):
+        """
+        Wraps the sync application with exception handling.
+        """
+
+        __traceback_hide__ = True  # noqa: F841
+
+        # Set the threadlocal for AsyncToSync
+        self.threadlocal.main_event_loop = loop
+        self.threadlocal.main_event_loop_pid = os.getpid()
+        self.threadlocal.task_context = task_context
+
+        # Run the function
+        # If we have an exception, run the function inside the except block
+        # after raising it so exc_info is correctly populated.
+        if exc_info[1]:
+            try:
+                raise exc_info[1]
+            except BaseException:
+                return func(*args, **kwargs)
+        else:
+            return func(*args, **kwargs)
+
+
+@overload
+def async_to_sync(
+    *,
+    force_new_loop: bool = False,
+) -> Callable[
+    [Callable[_P, Coroutine[Any, Any, _R]] | Callable[_P, Awaitable[_R]]],
+    Callable[_P, _R],
+]: ...
+
+
+@overload
+def async_to_sync(
+    awaitable: Callable[_P, Coroutine[Any, Any, _R]] | Callable[_P, Awaitable[_R]],
+    *,
+    force_new_loop: bool = False,
+) -> Callable[_P, _R]: ...
+
+
+def async_to_sync(
+    awaitable: None | (
+        Callable[_P, Coroutine[Any, Any, _R]] | Callable[_P, Awaitable[_R]]
+    ) = None,
+    *,
+    force_new_loop: bool = False,
+) -> (
+    Callable[
+        [Callable[_P, Coroutine[Any, Any, _R]] | Callable[_P, Awaitable[_R]]],
+        Callable[_P, _R],
+    ]
+    | Callable[_P, _R]
+):
+    if awaitable is None:
+        return lambda f: AsyncToSync(
+            f,
+            force_new_loop=force_new_loop,
+        )
+    return AsyncToSync(
+        awaitable,
+        force_new_loop=force_new_loop,
+    )
+
+
+@overload
+def sync_to_async(
+    *,
+    thread_sensitive: bool = True,
+    executor: Optional["ThreadPoolExecutor"] = None,
+    context: contextvars.Context | None = None,
+) -> Callable[[Callable[_P, _R]], Callable[_P, Coroutine[Any, Any, _R]]]: ...
+
+
+@overload
+def sync_to_async(
+    func: Callable[_P, _R],
+    *,
+    thread_sensitive: bool = True,
+    executor: Optional["ThreadPoolExecutor"] = None,
+    context: contextvars.Context | None = None,
+) -> Callable[_P, Coroutine[Any, Any, _R]]: ...
+
+
+def sync_to_async(
+    func: Callable[_P, _R] | None = None,
+    *,
+    thread_sensitive: bool = True,
+    executor: Optional["ThreadPoolExecutor"] = None,
+    context: contextvars.Context | None = None,
+) -> (
+    Callable[[Callable[_P, _R]], Callable[_P, Coroutine[Any, Any, _R]]]
+    | Callable[_P, Coroutine[Any, Any, _R]]
+):
+    if func is None:
+        return lambda f: SyncToAsync(
+            f,
+            thread_sensitive=thread_sensitive,
+            executor=executor,
+            context=context,
+        )
+    return SyncToAsync(
+        func,
+        thread_sensitive=thread_sensitive,
+        executor=executor,
+        context=context,
+    )

+ 137 - 0
venv/lib/python3.12/site-packages/asgiref/testing.py

@@ -0,0 +1,137 @@
+import asyncio
+import contextvars
+import time
+
+from .compatibility import guarantee_single_callable
+from .timeout import timeout as async_timeout
+
+
+class ApplicationCommunicator:
+    """
+    Runs an ASGI application in a test mode, allowing sending of
+    messages to it and retrieval of messages it sends.
+    """
+
+    def __init__(self, application, scope):
+        self._future = None
+        self.application = guarantee_single_callable(application)
+        self.scope = scope
+        self._input_queue = None
+        self._output_queue = None
+
+    # For Python 3.9 we need to lazily bind the queues, on 3.10+ they bind the
+    # event loop lazily.
+    @property
+    def input_queue(self):
+        if self._input_queue is None:
+            self._input_queue = asyncio.Queue()
+        return self._input_queue
+
+    @property
+    def output_queue(self):
+        if self._output_queue is None:
+            self._output_queue = asyncio.Queue()
+        return self._output_queue
+
+    @property
+    def future(self):
+        if self._future is None:
+            # Clear context - this ensures that context vars set in the testing scope
+            # are not "leaked" into the application which would normally begin with
+            # an empty context. In Python >= 3.11 this could also be written as:
+            # asyncio.create_task(..., context=contextvars.Context())
+            self._future = contextvars.Context().run(
+                asyncio.create_task,
+                self.application(
+                    self.scope, self.input_queue.get, self.output_queue.put
+                ),
+            )
+        return self._future
+
+    async def wait(self, timeout=1):
+        """
+        Waits for the application to stop itself and returns any exceptions.
+        """
+        try:
+            async with async_timeout(timeout):
+                try:
+                    await self.future
+                    self.future.result()
+                except asyncio.CancelledError:
+                    pass
+        finally:
+            if not self.future.done():
+                self.future.cancel()
+                try:
+                    await self.future
+                except asyncio.CancelledError:
+                    pass
+
+    def stop(self, exceptions=True):
+        future = self._future
+        if future is None:
+            return
+
+        if not future.done():
+            future.cancel()
+        elif exceptions:
+            # Give a chance to raise any exceptions
+            future.result()
+
+    def __del__(self):
+        # Clean up on deletion
+        try:
+            self.stop(exceptions=False)
+        except RuntimeError:
+            # Event loop already stopped
+            pass
+
+    async def send_input(self, message):
+        """
+        Sends a single message to the application
+        """
+        # Make sure there's not an exception to raise from the task
+        if self.future.done():
+            self.future.result()
+
+        # Give it the message
+        await self.input_queue.put(message)
+
+    async def receive_output(self, timeout=1):
+        """
+        Receives a single message from the application, with optional timeout.
+        """
+        # Make sure there's not an exception to raise from the task
+        if self.future.done():
+            self.future.result()
+        # Wait and receive the message
+        try:
+            async with async_timeout(timeout):
+                return await self.output_queue.get()
+        except asyncio.TimeoutError as e:
+            # See if we have another error to raise inside
+            if self.future.done():
+                self.future.result()
+            else:
+                self.future.cancel()
+                try:
+                    await self.future
+                except asyncio.CancelledError:
+                    pass
+            raise e
+
+    async def receive_nothing(self, timeout=0.1, interval=0.01):
+        """
+        Checks that there is no message to receive in the given time.
+        """
+        # Make sure there's not an exception to raise from the task
+        if self.future.done():
+            self.future.result()
+
+        # `interval` has precedence over `timeout`
+        start = time.monotonic()
+        while time.monotonic() - start < timeout:
+            if not self.output_queue.empty():
+                return False
+            await asyncio.sleep(interval)
+        return self.output_queue.empty()

+ 118 - 0
venv/lib/python3.12/site-packages/asgiref/timeout.py

@@ -0,0 +1,118 @@
+# This code is originally sourced from the aio-libs project "async_timeout",
+# under the Apache 2.0 license. You may see the original project at
+# https://github.com/aio-libs/async-timeout
+
+# It is vendored here to reduce chain-dependencies on this library, and
+# modified slightly to remove some features we don't use.
+
+
+import asyncio
+import warnings
+from types import TracebackType
+from typing import Any  # noqa
+from typing import Optional  # noqa
+
+
+class timeout:
+    """timeout context manager.
+
+    Useful in cases when you want to apply timeout logic around block
+    of code or in cases when asyncio.wait_for is not suitable. For example:
+
+    >>> with timeout(0.001):
+    ...     async with aiohttp.get('https://github.com') as r:
+    ...         await r.text()
+
+
+    timeout - value in seconds or None to disable timeout logic
+    loop - asyncio compatible event loop
+    """
+
+    def __init__(
+        self,
+        timeout: float | None,
+        *,
+        loop: asyncio.AbstractEventLoop | None = None,
+    ) -> None:
+        self._timeout = timeout
+        if loop is None:
+            loop = asyncio.get_running_loop()
+        else:
+            warnings.warn(
+                """The loop argument to timeout() is deprecated.""", DeprecationWarning
+            )
+        self._loop = loop
+        self._task = None  # type: Optional[asyncio.Task[Any]]
+        self._cancelled = False
+        self._cancel_handler = None  # type: Optional[asyncio.Handle]
+        self._cancel_at = None  # type: Optional[float]
+
+    def __enter__(self) -> "timeout":
+        return self._do_enter()
+
+    def __exit__(
+        self,
+        exc_type: type[BaseException],
+        exc_val: BaseException,
+        exc_tb: TracebackType,
+    ) -> bool | None:
+        self._do_exit(exc_type)
+        return None
+
+    async def __aenter__(self) -> "timeout":
+        return self._do_enter()
+
+    async def __aexit__(
+        self,
+        exc_type: type[BaseException],
+        exc_val: BaseException,
+        exc_tb: TracebackType,
+    ) -> None:
+        self._do_exit(exc_type)
+
+    @property
+    def expired(self) -> bool:
+        return self._cancelled
+
+    @property
+    def remaining(self) -> float | None:
+        if self._cancel_at is not None:
+            return max(self._cancel_at - self._loop.time(), 0.0)
+        else:
+            return None
+
+    def _do_enter(self) -> "timeout":
+        # Support Tornado 5- without timeout
+        # Details: https://github.com/python/asyncio/issues/392
+        if self._timeout is None:
+            return self
+
+        self._task = asyncio.current_task(self._loop)
+        if self._task is None:
+            raise RuntimeError(
+                "Timeout context manager should be used " "inside a task"
+            )
+
+        if self._timeout <= 0:
+            self._loop.call_soon(self._cancel_task)
+            return self
+
+        self._cancel_at = self._loop.time() + self._timeout
+        self._cancel_handler = self._loop.call_at(self._cancel_at, self._cancel_task)
+        return self
+
+    def _do_exit(self, exc_type: type[BaseException]) -> None:
+        if exc_type is asyncio.CancelledError and self._cancelled:
+            self._cancel_handler = None
+            self._task = None
+            raise asyncio.TimeoutError
+        if self._timeout is not None and self._cancel_handler is not None:
+            self._cancel_handler.cancel()
+            self._cancel_handler = None
+        self._task = None
+        return None
+
+    def _cancel_task(self) -> None:
+        if self._task is not None:
+            self._task.cancel()
+            self._cancelled = True

+ 267 - 0
venv/lib/python3.12/site-packages/asgiref/typing.py

@@ -0,0 +1,267 @@
+import sys
+from collections.abc import Awaitable, Callable, Iterable
+from typing import Any, Literal, Protocol, TypedDict, Union
+
+if sys.version_info >= (3, 11):
+    from typing import NotRequired
+else:
+    from typing_extensions import NotRequired
+
+__all__ = (
+    "ASGIVersions",
+    "HTTPScope",
+    "WebSocketScope",
+    "LifespanScope",
+    "WWWScope",
+    "Scope",
+    "HTTPRequestEvent",
+    "HTTPResponseStartEvent",
+    "HTTPResponseBodyEvent",
+    "HTTPResponseTrailersEvent",
+    "HTTPResponsePathsendEvent",
+    "HTTPServerPushEvent",
+    "HTTPDisconnectEvent",
+    "WebSocketConnectEvent",
+    "WebSocketAcceptEvent",
+    "WebSocketReceiveEvent",
+    "WebSocketSendEvent",
+    "WebSocketResponseStartEvent",
+    "WebSocketResponseBodyEvent",
+    "WebSocketDisconnectEvent",
+    "WebSocketCloseEvent",
+    "LifespanStartupEvent",
+    "LifespanShutdownEvent",
+    "LifespanStartupCompleteEvent",
+    "LifespanStartupFailedEvent",
+    "LifespanShutdownCompleteEvent",
+    "LifespanShutdownFailedEvent",
+    "ASGIReceiveEvent",
+    "ASGISendEvent",
+    "ASGIReceiveCallable",
+    "ASGISendCallable",
+    "ASGI2Protocol",
+    "ASGI2Application",
+    "ASGI3Application",
+    "ASGIApplication",
+)
+
+
+class ASGIVersions(TypedDict):
+    spec_version: str
+    version: Literal["2.0"] | Literal["3.0"]
+
+
+class HTTPScope(TypedDict):
+    type: Literal["http"]
+    asgi: ASGIVersions
+    http_version: str
+    method: str
+    scheme: str
+    path: str
+    raw_path: bytes
+    query_string: bytes
+    root_path: str
+    headers: Iterable[tuple[bytes, bytes]]
+    client: tuple[str, int] | None
+    server: tuple[str, int | None] | None
+    state: NotRequired[dict[str, Any]]
+    extensions: dict[str, dict[object, object]] | None
+
+
+class WebSocketScope(TypedDict):
+    type: Literal["websocket"]
+    asgi: ASGIVersions
+    http_version: str
+    scheme: str
+    path: str
+    raw_path: bytes
+    query_string: bytes
+    root_path: str
+    headers: Iterable[tuple[bytes, bytes]]
+    client: tuple[str, int] | None
+    server: tuple[str, int | None] | None
+    subprotocols: Iterable[str]
+    state: NotRequired[dict[str, Any]]
+    extensions: dict[str, dict[object, object]] | None
+
+
+class LifespanScope(TypedDict):
+    type: Literal["lifespan"]
+    asgi: ASGIVersions
+    state: NotRequired[dict[str, Any]]
+
+
+WWWScope = Union[HTTPScope, WebSocketScope]
+Scope = Union[HTTPScope, WebSocketScope, LifespanScope]
+
+
+class HTTPRequestEvent(TypedDict):
+    type: Literal["http.request"]
+    body: bytes
+    more_body: bool
+
+
+class HTTPResponseDebugEvent(TypedDict):
+    type: Literal["http.response.debug"]
+    info: dict[str, object]
+
+
+class HTTPResponseStartEvent(TypedDict):
+    type: Literal["http.response.start"]
+    status: int
+    headers: Iterable[tuple[bytes, bytes]]
+    trailers: bool
+
+
+class HTTPResponseBodyEvent(TypedDict):
+    type: Literal["http.response.body"]
+    body: bytes
+    more_body: bool
+
+
+class HTTPResponseTrailersEvent(TypedDict):
+    type: Literal["http.response.trailers"]
+    headers: Iterable[tuple[bytes, bytes]]
+    more_trailers: bool
+
+
+class HTTPResponsePathsendEvent(TypedDict):
+    type: Literal["http.response.pathsend"]
+    path: str
+
+
+class HTTPServerPushEvent(TypedDict):
+    type: Literal["http.response.push"]
+    path: str
+    headers: Iterable[tuple[bytes, bytes]]
+
+
+class HTTPDisconnectEvent(TypedDict):
+    type: Literal["http.disconnect"]
+
+
+class WebSocketConnectEvent(TypedDict):
+    type: Literal["websocket.connect"]
+
+
+class WebSocketAcceptEvent(TypedDict):
+    type: Literal["websocket.accept"]
+    subprotocol: str | None
+    headers: Iterable[tuple[bytes, bytes]]
+
+
+class WebSocketReceiveEvent(TypedDict):
+    type: Literal["websocket.receive"]
+    bytes: bytes | None
+    text: str | None
+
+
+class WebSocketSendEvent(TypedDict):
+    type: Literal["websocket.send"]
+    bytes: bytes | None
+    text: str | None
+
+
+class WebSocketResponseStartEvent(TypedDict):
+    type: Literal["websocket.http.response.start"]
+    status: int
+    headers: Iterable[tuple[bytes, bytes]]
+
+
+class WebSocketResponseBodyEvent(TypedDict):
+    type: Literal["websocket.http.response.body"]
+    body: bytes
+    more_body: bool
+
+
+class WebSocketDisconnectEvent(TypedDict):
+    type: Literal["websocket.disconnect"]
+    code: int
+    reason: str | None
+
+
+class WebSocketCloseEvent(TypedDict):
+    type: Literal["websocket.close"]
+    code: int
+    reason: str | None
+
+
+class LifespanStartupEvent(TypedDict):
+    type: Literal["lifespan.startup"]
+
+
+class LifespanShutdownEvent(TypedDict):
+    type: Literal["lifespan.shutdown"]
+
+
+class LifespanStartupCompleteEvent(TypedDict):
+    type: Literal["lifespan.startup.complete"]
+
+
+class LifespanStartupFailedEvent(TypedDict):
+    type: Literal["lifespan.startup.failed"]
+    message: str
+
+
+class LifespanShutdownCompleteEvent(TypedDict):
+    type: Literal["lifespan.shutdown.complete"]
+
+
+class LifespanShutdownFailedEvent(TypedDict):
+    type: Literal["lifespan.shutdown.failed"]
+    message: str
+
+
+ASGIReceiveEvent = Union[
+    HTTPRequestEvent,
+    HTTPDisconnectEvent,
+    WebSocketConnectEvent,
+    WebSocketReceiveEvent,
+    WebSocketDisconnectEvent,
+    LifespanStartupEvent,
+    LifespanShutdownEvent,
+]
+
+
+ASGISendEvent = Union[
+    HTTPResponseStartEvent,
+    HTTPResponseBodyEvent,
+    HTTPResponseTrailersEvent,
+    HTTPServerPushEvent,
+    HTTPDisconnectEvent,
+    WebSocketAcceptEvent,
+    WebSocketSendEvent,
+    WebSocketResponseStartEvent,
+    WebSocketResponseBodyEvent,
+    WebSocketCloseEvent,
+    LifespanStartupCompleteEvent,
+    LifespanStartupFailedEvent,
+    LifespanShutdownCompleteEvent,
+    LifespanShutdownFailedEvent,
+]
+
+
+ASGIReceiveCallable = Callable[[], Awaitable[ASGIReceiveEvent]]
+ASGISendCallable = Callable[[ASGISendEvent], Awaitable[None]]
+
+
+class ASGI2Protocol(Protocol):
+    def __init__(self, scope: Scope) -> None: ...
+
+    async def __call__(
+        self,
+        receive: ASGIReceiveCallable,
+        send: ASGISendCallable,
+    ) -> None: ...
+
+
+ASGI2Application = type[ASGI2Protocol]
+ASGI3Application = Callable[
+    [
+        Scope,
+        ASGIReceiveCallable,
+        ASGISendCallable,
+    ],
+    Awaitable[None],
+]
+ASGIApplication = Union[ASGI2Application, ASGI3Application]

+ 197 - 0
venv/lib/python3.12/site-packages/asgiref/wsgi.py

@@ -0,0 +1,197 @@
+import sys
+from collections import defaultdict
+from tempfile import SpooledTemporaryFile
+
+from asgiref.sync import AsyncToSync, sync_to_async
+
+
+class WsgiToAsgi:
+    """
+    Wraps a WSGI application to make it into an ASGI application.
+    """
+
+    def __init__(self, wsgi_application, duplicate_header_limit=100):
+        self.wsgi_application = wsgi_application
+        self.duplicate_header_limit = duplicate_header_limit
+
+    async def __call__(self, scope, receive, send):
+        """
+        ASGI application instantiation point.
+        We return a new WsgiToAsgiInstance here with the WSGI app
+        and the scope, ready to respond when it is __call__ed.
+        """
+        await WsgiToAsgiInstance(self.wsgi_application, self.duplicate_header_limit)(
+            scope, receive, send
+        )
+
+
+class WsgiToAsgiInstance:
+    """
+    Per-socket instance of a wrapped WSGI application
+    """
+
+    def __init__(self, wsgi_application, duplicate_header_limit=100):
+        self.wsgi_application = wsgi_application
+        self.duplicate_header_limit = duplicate_header_limit
+        self.response_started = False
+        self.response_content_length = None
+
+    async def __call__(self, scope, receive, send):
+        if scope["type"] != "http":
+            raise ValueError("WSGI wrapper received a non-HTTP scope")
+        self.scope = scope
+        with SpooledTemporaryFile(max_size=65536) as body:
+            # Alright, wait for the http.request messages
+            while True:
+                message = await receive()
+                if message["type"] != "http.request":
+                    raise ValueError("WSGI wrapper received a non-HTTP-request message")
+                body.write(message.get("body", b""))
+                if not message.get("more_body"):
+                    break
+            body.seek(0)
+            # Wrap send so it can be called from the subthread
+            self.sync_send = AsyncToSync(send)
+            # Call the WSGI app
+            await self.run_wsgi_app(body)
+
+    def build_environ(self, scope, body):
+        """
+        Builds a scope and request body into a WSGI environ object.
+        """
+        script_name = scope.get("root_path", "").encode("utf8").decode("latin1")
+        path_info = scope["path"].encode("utf8").decode("latin1")
+        if path_info.startswith(script_name):
+            path_info = path_info[len(script_name) :]
+        environ = {
+            "REQUEST_METHOD": scope["method"],
+            "SCRIPT_NAME": script_name,
+            "PATH_INFO": path_info,
+            "QUERY_STRING": scope["query_string"].decode("ascii"),
+            "SERVER_PROTOCOL": "HTTP/%s" % scope["http_version"],
+            "wsgi.version": (1, 0),
+            "wsgi.url_scheme": scope.get("scheme", "http"),
+            "wsgi.input": body,
+            "wsgi.errors": sys.stderr,
+            "wsgi.multithread": True,
+            "wsgi.multiprocess": True,
+            "wsgi.run_once": False,
+        }
+        # Get server name and port - required in WSGI, not in ASGI
+        if "server" in scope:
+            environ["SERVER_NAME"] = scope["server"][0]
+            environ["SERVER_PORT"] = str(scope["server"][1])
+        else:
+            environ["SERVER_NAME"] = "localhost"
+            environ["SERVER_PORT"] = "80"
+
+        if scope.get("client") is not None:
+            environ["REMOTE_ADDR"] = scope["client"][0]
+
+        # Go through headers and make them into environ entries
+        _headers = defaultdict(list)
+        for name, value in self.scope.get("headers", []):
+            name = name.decode("latin1")
+            if name == "content-length":
+                corrected_name = "CONTENT_LENGTH"
+            elif name == "content-type":
+                corrected_name = "CONTENT_TYPE"
+            else:
+                corrected_name = "HTTP_%s" % name.upper().replace("-", "_")
+            # HTTPbis say only ASCII chars are allowed in headers, but we latin1 just in case
+            value = value.decode("latin1")
+            if (
+                self.duplicate_header_limit
+                and len(_headers[corrected_name]) >= self.duplicate_header_limit
+            ):
+                raise ValueError(
+                    f"Too many duplicate headers: {corrected_name} exceeds limit of"
+                    f"{self.duplicate_header_limit}"
+                )
+            _headers[corrected_name].append(value)
+        for name, values in _headers.items():
+            environ[name] = ",".join(values)
+        return environ
+
+    def start_response(self, status, response_headers, exc_info=None):
+        """
+        WSGI start_response callable.
+        """
+        # Don't allow re-calling once response has begun
+        if self.response_started:
+            raise exc_info[1].with_traceback(exc_info[2])
+        # Don't allow re-calling without exc_info
+        if hasattr(self, "response_start") and exc_info is None:
+            raise ValueError(
+                "You cannot call start_response a second time without exc_info"
+            )
+        # Extract status code
+        status_code, _ = status.split(" ", 1)
+        status_code = int(status_code)
+        # Extract headers
+        headers = [
+            (name.lower().encode("ascii"), value.encode("ascii"))
+            for name, value in response_headers
+        ]
+        # Extract content-length
+        self.response_content_length = None
+        for name, value in response_headers:
+            if name.lower() == "content-length":
+                self.response_content_length = int(value)
+        # Build and send response start message.
+        self.response_start = {
+            "type": "http.response.start",
+            "status": status_code,
+            "headers": headers,
+        }
+
+    @sync_to_async
+    def run_wsgi_app(self, body):
+        """
+        Called in a subthread to run the WSGI app. We encapsulate like
+        this so that the start_response callable is called in the same thread.
+        """
+        # Translate the scope and incoming request body into a WSGI environ
+        try:
+            environ = self.build_environ(self.scope, body)
+        except ValueError:
+            # Return 400 Bad Request if header limit exceeded
+            self.sync_send(
+                {
+                    "type": "http.response.start",
+                    "status": 400,
+                    "headers": [(b"content-type", b"text/plain")],
+                }
+            )
+            self.sync_send(
+                {
+                    "type": "http.response.body",
+                    "body": b"Bad Request: Too many duplicate headers",
+                }
+            )
+            return
+        # Run the WSGI app
+        bytes_sent = 0
+        for output in self.wsgi_application(environ, self.start_response):
+            # If this is the first response, include the response headers
+            if not self.response_started:
+                self.response_started = True
+                self.sync_send(self.response_start)
+            # If the application supplies a Content-Length header
+            if self.response_content_length is not None:
+                # The server should not transmit more bytes to the client than the header allows
+                bytes_allowed = self.response_content_length - bytes_sent
+                if len(output) > bytes_allowed:
+                    output = output[:bytes_allowed]
+            self.sync_send(
+                {"type": "http.response.body", "body": output, "more_body": True}
+            )
+            bytes_sent += len(output)
+            # The server should stop iterating over the response when enough data has been sent
+            if bytes_sent == self.response_content_length:
+                break
+        # Close connection
+        if not self.response_started:
+            self.response_started = True
+            self.sync_send(self.response_start)
+        self.sync_send({"type": "http.response.body"})

+ 1 - 0
venv/lib/python3.12/site-packages/certifi-2026.7.22.dist-info/INSTALLER

@@ -0,0 +1 @@
+pip

+ 78 - 0
venv/lib/python3.12/site-packages/certifi-2026.7.22.dist-info/METADATA

@@ -0,0 +1,78 @@
+Metadata-Version: 2.4
+Name: certifi
+Version: 2026.7.22
+Summary: Python package for providing Mozilla's CA Bundle.
+Home-page: https://github.com/certifi/python-certifi
+Author: Kenneth Reitz
+Author-email: me@kennethreitz.com
+License: MPL-2.0
+Project-URL: Source, https://github.com/certifi/python-certifi
+Classifier: Development Status :: 5 - Production/Stable
+Classifier: Intended Audience :: Developers
+Classifier: License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)
+Classifier: Natural Language :: English
+Classifier: Programming Language :: Python
+Classifier: Programming Language :: Python :: 3
+Classifier: Programming Language :: Python :: 3 :: Only
+Classifier: Programming Language :: Python :: 3.7
+Classifier: Programming Language :: Python :: 3.8
+Classifier: Programming Language :: Python :: 3.9
+Classifier: Programming Language :: Python :: 3.10
+Classifier: Programming Language :: Python :: 3.11
+Classifier: Programming Language :: Python :: 3.12
+Classifier: Programming Language :: Python :: 3.13
+Classifier: Programming Language :: Python :: 3.14
+Requires-Python: >=3.7
+License-File: LICENSE
+Dynamic: author
+Dynamic: author-email
+Dynamic: classifier
+Dynamic: description
+Dynamic: home-page
+Dynamic: license
+Dynamic: license-file
+Dynamic: project-url
+Dynamic: requires-python
+Dynamic: summary
+
+Certifi: Python SSL Certificates
+================================
+
+Certifi provides Mozilla's carefully curated collection of Root Certificates for
+validating the trustworthiness of SSL certificates while verifying the identity
+of TLS hosts. It has been extracted from the `Requests`_ project.
+
+Installation
+------------
+
+``certifi`` is available on PyPI. Simply install it with ``pip``::
+
+    $ pip install certifi
+
+Usage
+-----
+
+To reference the installed certificate authority (CA) bundle, you can use the
+built-in function::
+
+    >>> import certifi
+
+    >>> certifi.where()
+    '/usr/local/lib/python3.7/site-packages/certifi/cacert.pem'
+
+Or from the command line::
+
+    $ python -m certifi
+    /usr/local/lib/python3.7/site-packages/certifi/cacert.pem
+
+Enjoy!
+
+.. _`Requests`: https://requests.readthedocs.io/en/latest/
+
+Addition/Removal of Certificates
+--------------------------------
+
+Certifi does not support any addition/removal or other modification of the
+CA trust store content. This project is intended to provide a reliable and
+highly portable root of trust to python deployments. Look to upstream projects
+for methods to use alternate trust.

+ 18 - 0
venv/lib/python3.12/site-packages/certifi-2026.7.22.dist-info/RECORD

@@ -0,0 +1,18 @@
+certifi-2026.7.22.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
+certifi-2026.7.22.dist-info/METADATA,sha256=71rxY4-7I2dqw8V3ffz8LNnDSP5Bcu1bo9J3ZVskgJA,2474
+certifi-2026.7.22.dist-info/RECORD,,
+certifi-2026.7.22.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
+certifi-2026.7.22.dist-info/licenses/LICENSE,sha256=6TcW2mucDVpKHfYP5pWzcPBpVgPSH2-D8FPkLPwQyvc,989
+certifi-2026.7.22.dist-info/top_level.txt,sha256=KMu4vUCfsjLrkPbSNdgdekS-pVJzBAJFO__nI8NF6-U,8
+certifi/__init__.py,sha256=elLl9CBfmz0SoxiYzqp-P2g30ZzCMHALyuGp2R0UhsE,94
+certifi/__main__.py,sha256=xBBoj905TUWBLRGANOcf7oi6e-3dMP4cEoG9OyMs11g,243
+certifi/__pycache__/__init__.cpython-312.pyc,,
+certifi/__pycache__/__main__.cpython-312.pyc,,
+certifi/__pycache__/core.cpython-312.pyc,,
+certifi/cacert.pem,sha256=nMKndLUZjc_xTZvh5mCR9TiXXYZ84Cmpa84VpV39cw8,240216
+certifi/core.py,sha256=XFXycndG5pf37ayeF8N32HUuDafsyhkVMbO4BAPWHa0,3394
+certifi/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
+certifi/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
+certifi/tests/__pycache__/__init__.cpython-312.pyc,,
+certifi/tests/__pycache__/test_certify.cpython-312.pyc,,
+certifi/tests/test_certify.py,sha256=fBedHt839-kSsTNmNzX7RsYzjV0OX4kV0Rw7wofSJ7g,467

+ 5 - 0
venv/lib/python3.12/site-packages/certifi-2026.7.22.dist-info/WHEEL

@@ -0,0 +1,5 @@
+Wheel-Version: 1.0
+Generator: setuptools (83.0.0)
+Root-Is-Purelib: true
+Tag: py3-none-any
+

+ 20 - 0
venv/lib/python3.12/site-packages/certifi-2026.7.22.dist-info/licenses/LICENSE

@@ -0,0 +1,20 @@
+This package contains a modified version of ca-bundle.crt:
+
+ca-bundle.crt -- Bundle of CA Root Certificates
+
+This is a bundle of X.509 certificates of public Certificate Authorities
+(CA). These were automatically extracted from Mozilla's root certificates
+file (certdata.txt).  This file can be found in the mozilla source tree:
+https://hg.mozilla.org/mozilla-central/file/tip/security/nss/lib/ckfw/builtins/certdata.txt
+It contains the certificates in PEM format and therefore
+can be directly used with curl / libcurl / php_curl, or with
+an Apache+mod_ssl webserver for SSL client authentication.
+Just configure this file as the SSLCACertificateFile.#
+
+***** BEGIN LICENSE BLOCK *****
+This Source Code Form is subject to the terms of the Mozilla Public License,
+v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain
+one at http://mozilla.org/MPL/2.0/.
+
+***** END LICENSE BLOCK *****
+@(#) $RCSfile: certdata.txt,v $ $Revision: 1.80 $ $Date: 2011/11/03 15:11:58 $

+ 1 - 0
venv/lib/python3.12/site-packages/certifi-2026.7.22.dist-info/top_level.txt

@@ -0,0 +1 @@
+certifi

+ 4 - 0
venv/lib/python3.12/site-packages/certifi/__init__.py

@@ -0,0 +1,4 @@
+from .core import contents, where
+
+__all__ = ["contents", "where"]
+__version__ = "2026.07.22"

+ 12 - 0
venv/lib/python3.12/site-packages/certifi/__main__.py

@@ -0,0 +1,12 @@
+import argparse
+
+from certifi import contents, where
+
+parser = argparse.ArgumentParser()
+parser.add_argument("-c", "--contents", action="store_true")
+args = parser.parse_args()
+
+if args.contents:
+    print(contents())
+else:
+    print(where())

二進制
venv/lib/python3.12/site-packages/certifi/__pycache__/__init__.cpython-312.pyc


部分文件因文件數量過多而無法顯示