chore: Initialized project virtual environment, installed dependencies, and added basic template and static files.

This commit is contained in:
araizaeduardo
2026-03-17 23:44:26 -07:00
commit 24ad30dc56
3147 changed files with 701387 additions and 0 deletions

BIN
.sync.ffs_db Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

40
database.py Normal file
View File

@@ -0,0 +1,40 @@
from sqlalchemy import create_engine, Column, Integer, String, Text, Boolean, DateTime
from sqlalchemy.orm import declarative_base, sessionmaker
from datetime import datetime
DATABASE_URL = "sqlite:///./gtravel.db"
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
class AgencySetting(Base):
__tablename__ = "agency_settings"
id = Column(Integer, primary_key=True, index=True)
setting_key = Column(String, unique=True, index=True)
setting_value = Column(String)
class Tour(Base):
__tablename__ = "tours"
id = Column(Integer, primary_key=True, index=True)
category = Column(String, index=True) # tour, surf, volcano
title_en = Column(String)
title_es = Column(String)
desc_en = Column(Text)
desc_es = Column(Text)
price = Column(String)
image_url = Column(String)
class LeadForm(Base):
__tablename__ = "lead_forms"
id = Column(Integer, primary_key=True, index=True)
name = Column(String)
email = Column(String)
phone = Column(String)
message = Column(Text)
interested_in = Column(String)
wants_retell_ai = Column(Boolean, default=False)
created_at = Column(DateTime, default=datetime.utcnow)
Base.metadata.create_all(bind=engine)

BIN
gtravel.db Normal file

Binary file not shown.

307
main.py Normal file
View File

@@ -0,0 +1,307 @@
import os
import smtplib
from email.message import EmailMessage
from functools import wraps
import requests as http_requests
from flask import Flask, render_template, request, redirect, url_for, abort, Response
from database import SessionLocal, AgencySetting, Tour, LeadForm
app = Flask(__name__, static_folder="static", template_folder="templates")
app.secret_key = os.environ.get("SECRET_KEY", "gtravel-secret-key-change-me")
# ---------- helpers ----------
def get_db():
return SessionLocal()
def set_setting(db, key, value):
setting = db.query(AgencySetting).filter(AgencySetting.setting_key == key).first()
if setting:
setting.setting_value = value
else:
db.add(AgencySetting(setting_key=key, setting_value=value))
db.commit()
def check_auth(username, password):
return username == "admin" and password == "admin"
def require_admin(f):
@wraps(f)
def decorated(*args, **kwargs):
auth = request.authorization
if not auth or not check_auth(auth.username, auth.password):
return Response(
"Unauthorized", 401,
{"WWW-Authenticate": 'Basic realm="Login Required"'}
)
return f(*args, **kwargs)
return decorated
def send_email_mailtrap(settings, lead):
host = settings.get("mailtrap_host")
port = settings.get("mailtrap_port")
user = settings.get("mailtrap_user")
password = settings.get("mailtrap_password")
if not all([host, port, user, password]):
return False
msg = EmailMessage()
msg.set_content(
f"New Lead: {lead.name}\nEmail: {lead.email}\nPhone: {lead.phone}"
f"\nMessage: {lead.message}\nInterested in: {lead.interested_in}"
f"\nWants Retell AI: {lead.wants_retell_ai}"
)
msg["Subject"] = "New Inquiry from GTravel"
msg["From"] = settings.get("agency_email", "no-reply@gtravel.com")
msg["To"] = settings.get("agency_email", "admin@gtravel.com")
try:
with smtplib.SMTP(host, int(port)) as server:
server.login(user, password)
server.send_message(msg)
return True
except Exception as e:
print(f"Error sending email: {e}")
return False
def save_upload(file_storage, subfolder="images"):
"""Save a werkzeug FileStorage and return the URL path."""
dest_dir = os.path.join("static", subfolder)
os.makedirs(dest_dir, exist_ok=True)
file_path = os.path.join(dest_dir, file_storage.filename)
file_storage.save(file_path)
return f"/{file_path}"
# ---------- PUBLIC ROUTES ----------
@app.route("/")
def home():
lang = request.args.get("lang", "en")
db = get_db()
try:
tours = db.query(Tour).filter(Tour.category == "tour").all()
surfs = db.query(Tour).filter(Tour.category == "surf").all()
volcanoes = db.query(Tour).filter(Tour.category == "volcano").all()
settings = {s.setting_key: s.setting_value for s in db.query(AgencySetting).all()}
return render_template("index.html",
lang=lang,
tours=tours,
surfs=surfs,
volcanoes=volcanoes,
settings=settings,
turnstile_site_key=settings.get("turnstile_site_key", "")
)
finally:
db.close()
@app.route("/tour/<int:tour_id>")
def view_tour(tour_id):
lang = request.args.get("lang", "en")
db = get_db()
try:
tour = db.query(Tour).filter(Tour.id == tour_id).first()
if not tour:
abort(404)
settings = {s.setting_key: s.setting_value for s in db.query(AgencySetting).all()}
return render_template("detail.html",
lang=lang,
tour=tour,
settings=settings,
turnstile_site_key=settings.get("turnstile_site_key", "")
)
finally:
db.close()
@app.route("/submit", methods=["POST"])
def submit_form():
lang = request.args.get("lang", "en")
db = get_db()
try:
settings_dict = {s.setting_key: s.setting_value for s in db.query(AgencySetting).all()}
secret_key = settings_dict.get("turnstile_secret_key")
cf_turnstile_response = request.form.get("cf-turnstile-response")
if secret_key and cf_turnstile_response:
verify_url = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
verify_data = {"secret": secret_key, "response": cf_turnstile_response}
resp = http_requests.post(verify_url, data=verify_data)
if not resp.json().get("success"):
print("Turnstile verification failed")
wants_retell = request.form.get("wants_retell_ai") == "true"
lead = LeadForm(
name=request.form["name"],
email=request.form["email"],
phone=request.form["phone"],
message=request.form.get("message", ""),
interested_in=request.form["interested_in"],
wants_retell_ai=wants_retell
)
db.add(lead)
db.commit()
db.refresh(lead)
send_email_mailtrap(settings_dict, lead)
if wants_retell:
retell_api = settings_dict.get("retell_api_key")
retell_agent_id = settings_dict.get("retell_agent_id")
if retell_api and retell_agent_id:
try:
headers = {"Authorization": f"Bearer {retell_api}"}
payload = {"agent_id": retell_agent_id, "to_number": lead.phone}
# http_requests.post("https://api.retellai.com/create-phone-call", json=payload, headers=headers)
print(f"Initiating Retell AI call to {lead.phone}")
except Exception as e:
print(f"Retell error: {e}")
return redirect(f"/?lang={lang}&submitted=true")
finally:
db.close()
# ---------- ADMIN ROUTES ----------
@app.route("/admin")
@require_admin
def admin_dashboard():
db = get_db()
try:
tours = db.query(Tour).all()
leads = db.query(LeadForm).order_by(LeadForm.created_at.desc()).all()
settings = {s.setting_key: s.setting_value for s in db.query(AgencySetting).all()}
return render_template("dashboard.html",
tours=tours,
leads=leads,
settings=settings
)
finally:
db.close()
@app.route("/admin/settings", methods=["POST"])
@require_admin
def admin_settings():
db = get_db()
try:
updates = {
"agency_name": request.form.get("agency_name", ""),
"agency_email": request.form.get("agency_email", ""),
"logo_url": request.form.get("logo_url", ""),
"hero_video_url": request.form.get("hero_video_url", ""),
"mailtrap_host": request.form.get("mailtrap_host", ""),
"mailtrap_port": request.form.get("mailtrap_port", ""),
"mailtrap_user": request.form.get("mailtrap_user", ""),
"mailtrap_password": request.form.get("mailtrap_password", ""),
"turnstile_site_key": request.form.get("turnstile_site_key", ""),
"turnstile_secret_key": request.form.get("turnstile_secret_key", ""),
"retell_api_key": request.form.get("retell_api_key", ""),
"retell_number": request.form.get("retell_number", ""),
"retell_agent_id": request.form.get("retell_agent_id", ""),
"retell_enabled": "true" if request.form.get("retell_enabled") == "on" else "false"
}
logo_file = request.files.get("logo_upload")
if logo_file and logo_file.filename:
updates["logo_url"] = save_upload(logo_file, "images")
video_file = request.files.get("hero_video_upload")
if video_file and video_file.filename:
updates["hero_video_url"] = save_upload(video_file, "videos")
for k, v in updates.items():
set_setting(db, k, v)
return redirect(url_for("admin_dashboard"))
finally:
db.close()
@app.route("/admin/tours", methods=["POST"])
@require_admin
def admin_add_tour():
db = get_db()
try:
final_image_url = request.form.get("image_url", "")
image_file = request.files.get("image_upload")
if image_file and image_file.filename:
final_image_url = save_upload(image_file, "images")
tour = Tour(
category=request.form["category"],
title_en=request.form["title_en"],
title_es=request.form["title_es"],
desc_en=request.form["desc_en"],
desc_es=request.form["desc_es"],
price=request.form["price"],
image_url=final_image_url
)
db.add(tour)
db.commit()
return redirect(url_for("admin_dashboard"))
finally:
db.close()
@app.route("/admin/tours/update/<int:tour_id>", methods=["POST"])
@require_admin
def admin_update_tour(tour_id):
db = get_db()
try:
tour = db.query(Tour).filter(Tour.id == tour_id).first()
if tour:
final_image_url = request.form.get("image_url", "")
image_file = request.files.get("image_upload")
if image_file and image_file.filename:
final_image_url = save_upload(image_file, "images")
elif final_image_url == "" and tour.image_url:
final_image_url = tour.image_url
tour.category = request.form["category"]
tour.title_en = request.form["title_en"]
tour.title_es = request.form["title_es"]
tour.desc_en = request.form["desc_en"]
tour.desc_es = request.form["desc_es"]
tour.price = request.form["price"]
tour.image_url = final_image_url
db.commit()
return redirect(url_for("admin_dashboard"))
finally:
db.close()
@app.route("/admin/tours/delete/<int:tour_id>", methods=["POST"])
@require_admin
def admin_delete_tour(tour_id):
db = get_db()
try:
tour = db.query(Tour).filter(Tour.id == tour_id).first()
if tour:
db.delete(tour)
db.commit()
return redirect(url_for("admin_dashboard"))
finally:
db.close()
# ---------- RUN ----------
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8000, debug=True)

5
requirements.txt Normal file
View File

@@ -0,0 +1,5 @@
flask
sqlalchemy
jinja2
requests
gunicorn

51
seed.py Normal file
View File

@@ -0,0 +1,51 @@
from database import SessionLocal, Tour, AgencySetting
def seed_db():
db = SessionLocal()
# Check if empty
if db.query(Tour).count() == 0:
tours = [
Tour(
category="tour",
title_en="Tikal Mayan Kingdom",
title_es="Reino Maya de Tikal",
desc_en="Explore the heart of the Mayan world in the jungles of Petén. Witness towering pyramids and abundant wildlife.",
desc_es="Explora el corazón del mundo maya en la selva de Petén. Contempla imponentes pirámides y abundante vida silvestre.",
price="199.00",
image_url="/static/images/mayan_ruins_tikal.png"
),
Tour(
category="surf",
title_en="El Paredón Surf Experience",
title_es="Experiencia de Surf en El Paredón",
desc_en="Catch the best waves in Central America on volcanic black sand beaches. Perfect for all skill levels.",
desc_es="Atrapa las mejores olas de Centroamérica en playas de arena negra volcánica. Perfecto para todos los niveles.",
price="85.00",
image_url="/static/images/surf_el_paredon.png"
),
Tour(
category="volcano",
title_en="Acatenango & Fuego Overnight",
title_es="Acatenango y Fuego (2 Días)",
desc_en="Hike up Acatenango to witness the spectacular eruptions of Volcán de Fuego under the stars.",
desc_es="Sube al Acatenango para presenciar las espectaculares erupciones del Volcán de Fuego bajo las estrellas.",
price="120.00",
image_url="/static/images/volcano_fuego.png"
)
]
db.add_all(tours)
# Add basic agency settings
settings = [
AgencySetting(setting_key="agency_name", setting_value="GTravel"),
AgencySetting(setting_key="agency_email", setting_value="info@gtravel.com"),
]
db.add_all(settings)
db.commit()
db.close()
if __name__ == "__main__":
seed_db()
print("Database seeded!")

479
static/css/style.css Normal file
View File

@@ -0,0 +1,479 @@
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;800&display=swap');
:root {
--primary-color: #c59d53;
/* Gold/Tan */
--secondary-color: #ffffff;
/* Tropical Blue */
--dark-bg: #001938;
/* Dark Blue */
--light-bg: #EAF4F4;
--text-light: #F0F4F8;
--text-dark: #1A1A1A;
--glass-bg: rgba(255, 255, 255, 0.1);
--glass-border: rgba(255, 255, 255, 0.2);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
font-family: 'Outfit', sans-serif;
}
body {
background: linear-gradient(135deg, var(--dark-bg), #000B1A);
color: var(--text-light);
min-height: 100vh;
overflow-x: hidden;
}
a {
text-decoration: none;
color: inherit;
}
/* Glassmorphism Classes */
.glass-panel {
background: var(--glass-bg);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--glass-border);
border-radius: 16px;
padding: 2rem;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.glass-panel:hover {
transform: translateY(-5px);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5);
}
/* Navbar */
.navbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem 5%;
background: rgba(0, 26, 51, 0.8);
backdrop-filter: blur(10px);
position: sticky;
top: 0;
z-index: 1000;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.5);
}
.nav-brand {
display: flex;
align-items: center;
font-size: 1.8rem;
font-weight: 800;
color: var(--primary-color);
letter-spacing: 2px;
}
.nav-brand img {
height: 40px;
margin-right: 15px;
}
.nav-links {
display: flex;
gap: 2rem;
}
.nav-links a {
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1px;
font-size: 0.9rem;
transition: color 0.3s ease;
position: relative;
}
.nav-links a:hover {
color: var(--primary-color);
}
.nav-links a::after {
content: '';
position: absolute;
width: 0%;
height: 2px;
bottom: -5px;
left: 0;
background-color: var(--primary-color);
transition: width 0.3s ease;
}
.nav-links a:hover::after {
width: 100%;
}
.lang-switch {
background: var(--primary-color);
color: var(--dark-bg);
padding: 0.4rem 1rem;
border-radius: 20px;
font-weight: 800;
transition: all 0.3s ease;
}
.lang-switch:hover {
box-shadow: 0 0 15px var(--primary-color);
}
/* Hero Section */
.hero {
min-height: 80vh;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
padding: 2rem;
position: relative;
overflow: hidden;
}
.hero-video {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
z-index: 0;
pointer-events: none;
flex-shrink: 0;
}
.hero-overlay {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
background: linear-gradient(to bottom, rgba(0, 25, 56, 0.6), rgba(0, 25, 56, 0.85));
z-index: 1;
pointer-events: none;
flex-shrink: 0;
}
.hero-content {
position: relative;
z-index: 3;
width: 100%;
max-width: 800px;
margin: 0 auto;
}
.hero::before {
content: '';
position: absolute;
inset: 0;
width: 100%;
height: 100%;
background: radial-gradient(circle at center, rgba(212, 175, 55, 0.1) 0%, transparent 70%);
pointer-events: none;
z-index: 2;
flex-shrink: 0;
}
.hero-content h1 {
font-size: 4.5rem;
font-weight: 800;
margin-bottom: 1rem;
background: linear-gradient(45deg, var(--primary-color), #FFF);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
animation: fadeInDown 1s ease;
}
.hero-content p {
font-size: 1.2rem;
line-height: 1.6;
max-width: 600px;
margin: 0 auto 2rem;
color: #CCC;
animation: fadeInUp 1s ease 0.3s backwards;
}
.btn {
display: inline-block;
padding: 1rem 2rem;
border-radius: 30px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 1px;
cursor: pointer;
transition: all 0.3s ease;
border: none;
}
.btn-primary {
background: var(--primary-color);
color: var(--dark-bg);
box-shadow: 0 4px 15px rgba(212, 175, 55, 0.4);
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(212, 175, 55, 0.6);
}
/* Sections Grid */
.section-title {
text-align: center;
font-size: 2.5rem;
margin: 4rem 0 2rem;
color: var(--primary-color);
text-transform: uppercase;
letter-spacing: 3px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 2rem;
padding: 0 5%;
margin-bottom: 4rem;
}
.card {
border-radius: 16px;
overflow: hidden;
background: rgba(255, 255, 255, 0.05);
transition: transform 0.4s ease;
}
.card:hover {
transform: translateY(-10px) scale(1.02);
}
.card-img {
width: 100%;
height: 200px;
object-fit: cover;
border-bottom: 2px solid var(--primary-color);
}
.card-content {
padding: 1.5rem;
}
.card-content h3 {
font-size: 1.5rem;
margin-bottom: 0.5rem;
color: var(--secondary-color);
}
.card-content p {
color: #BBB;
font-size: 0.95rem;
margin-bottom: 1rem;
}
.price {
font-weight: 800;
color: var(--primary-color);
font-size: 1.2rem;
display: block;
margin-top: 1rem;
}
/* Form Section */
.contact-section {
padding: 4rem 5%;
display: flex;
justify-content: center;
}
.contact-form {
max-width: 600px;
width: 100%;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 600;
color: var(--primary-color);
}
.form-control {
width: 100%;
padding: 1rem;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 8px;
color: white;
font-family: 'Outfit', sans-serif;
transition: border-color 0.3s ease;
}
.form-control:focus {
outline: none;
border-color: var(--primary-color);
}
.checkbox-wrap {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 1rem;
}
.checkbox-wrap input[type="checkbox"] {
width: 20px;
height: 20px;
accent-color: var(--primary-color);
}
/* Dashboard specific */
.admin-container {
padding: 3rem 5%;
}
.admin-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
}
.admin-table {
width: 100%;
border-collapse: collapse;
margin-top: 1rem;
}
.admin-table th,
.admin-table td {
padding: 1rem;
text-align: left;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.admin-table th {
color: var(--primary-color);
}
@keyframes fadeInDown {
from {
opacity: 0;
transform: translateY(-30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Tabs */
.tabs-header {
display: flex;
flex-wrap: wrap;
gap: 1rem;
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--glass-border);
}
.tab-btn {
background: var(--glass-bg);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--glass-border);
color: #CCC;
font-size: 1.1rem;
font-weight: 600;
cursor: pointer;
padding: 0.8rem 1.5rem;
transition: all 0.3s ease;
border-radius: 12px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
}
.tab-btn:hover {
background: rgba(255, 255, 255, 0.15);
transform: translateY(-2px);
color: var(--text-light);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3);
}
.tab-btn.active {
color: var(--dark-bg);
background: var(--primary-color);
border-color: var(--primary-color);
box-shadow: 0 4px 15px rgba(212, 175, 55, 0.4);
}
/* Hamburger */
.hamburger {
display: none;
flex-direction: column;
gap: 5px;
cursor: pointer;
}
.hamburger div {
width: 25px;
height: 3px;
background-color: var(--primary-color);
border-radius: 3px;
transition: all 0.3s ease;
}
/* Responsive */
@media (max-width: 768px) {
.hero-content h1 {
font-size: 3rem;
}
.admin-grid {
grid-template-columns: 1fr;
}
.hamburger {
display: flex;
}
.nav-links {
display: none;
flex-direction: column;
position: absolute;
top: 100%;
left: 0;
width: 100%;
background: rgba(0, 26, 51, 0.95);
backdrop-filter: blur(10px);
padding: 2rem;
gap: 1.5rem;
align-items: center;
border-bottom: 2px solid var(--primary-color);
}
.nav-links.mobile-active {
display: flex;
animation: fadeInDown 0.3s ease forwards;
}
.tabs-header {
flex-direction: column;
}
}

BIN
static/images/logo.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
static/images/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 685 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 767 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 702 KiB

Binary file not shown.

71
templates/base.html Normal file
View File

@@ -0,0 +1,71 @@
<!DOCTYPE html>
<html lang="{{ lang|default('en') }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ settings.get('agency_name', 'G Travel') }} | Tours, Surf, Volcanoes</title>
<meta name="description" content="Explore beautiful Guatemala with G Travel. Experience tours, surfing at El Paredon, and active volcanoes.">
<link rel="stylesheet" href="/static/css/style.css?v=2">
{% if turnstile_site_key %}
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
{% endif %}
</head>
<body>
<nav class="navbar">
<div class="nav-brand">
<a href="/" style="display: flex; align-items: center; text-decoration: none; color: var(--primary-color);">
{% if settings.get('logo_url') %}
<img src="{{ settings.get('logo_url') }}" alt="G Travel Logo">
{% else %}
<i>G</i> TRAVEL
{% endif %}
</a>
</div>
<!-- Hamburger Menu Button -->
<div class="hamburger" id="hamburger-menu">
<div></div>
<div></div>
<div></div>
</div>
<div class="nav-links" id="nav-links">
<a href="/#tours">{% if lang == 'es' %}Tours{% else %}Tours{% endif %}</a>
<a href="/#surf">{% if lang == 'es' %}Surf{% else %}Surf{% endif %}</a>
<a href="/#volcanoes">{% if lang == 'es' %}Volcanes{% else %}Volcanoes{% endif %}</a>
<a href="/#contact">{% if lang == 'es' %}Contacto{% else %}Contact{% endif %}</a>
<a href="?lang={% if lang == 'en' %}es{% else %}en{% endif %}" class="lang-switch">
{% if lang == 'en' %}Español{% else %}English{% endif %}
</a>
<a href="/admin" style="font-size: 0.8rem; opacity: 0.8;">Admin</a>
</div>
</nav>
<main>
{% block content %}{% endblock %}
</main>
<script>
document.getElementById('hamburger-menu').addEventListener('click', function() {
var navLinks = document.getElementById('nav-links');
if (navLinks.style.display === 'flex' && navLinks.classList.contains('mobile-active')) {
navLinks.style.display = 'none';
navLinks.classList.remove('mobile-active');
} else {
navLinks.style.display = 'flex';
navLinks.classList.add('mobile-active');
}
});
// Close menu when clicking a link
document.querySelectorAll('.nav-links a').forEach(link => {
link.addEventListener('click', () => {
var navLinks = document.getElementById('nav-links');
if (window.innerWidth <= 768) {
navLinks.style.display = 'none';
navLinks.classList.remove('mobile-active');
}
});
});
</script>
</body>
</html>

282
templates/dashboard.html Normal file
View File

@@ -0,0 +1,282 @@
{% extends "base.html" %}
{% block content %}
<div class="admin-container">
<h1 style="color: var(--primary-color); margin-bottom: 2rem;">Admin Dashboard</h1>
<div class="tabs-header">
<button class="tab-btn active" onclick="openTab(event, 'tab-leads')">Leads & Forms</button>
<button class="tab-btn" onclick="openTab(event, 'tab-tours')">Tours & Activities</button>
<button class="tab-btn" onclick="openTab(event, 'tab-settings')">System Settings</button>
</div>
<!-- Leads Tab -->
<div id="tab-leads" class="tab-content glass-panel" style="display: block;">
<h2 style="color: var(--primary-color); margin-bottom: 1.5rem;">Recent Leads</h2>
<table class="admin-table" style="width: 100%;">
<thead>
<tr>
<th>Date</th>
<th>Name</th>
<th>Email</th>
<th>Phone</th>
<th>Interest</th>
<th>Retell AI</th>
<th>Message</th>
</tr>
</thead>
<tbody>
{% for lead in leads %}
<tr>
<td>{{ lead.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
<td>{{ lead.name }}</td>
<td>{{ lead.email }}</td>
<td>{{ lead.phone }}</td>
<td>{{ lead.interested_in }}</td>
<td>{{ 'Yes' if lead.wants_retell_ai else 'No' }}</td>
<td>{{ lead.message }}</td>
</tr>
{% else %}
<tr><td colspan="7">No leads yet.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Tours Tab -->
<div id="tab-tours" class="tab-content" style="display: none;">
<div class="admin-grid">
<!-- Add Tour Form -->
<div class="glass-panel" style="margin-bottom: 2rem;">
<h2 id="tour-form-title" style="color: var(--primary-color); margin-bottom: 1.5rem;">Add Activity / Tour</h2>
<form id="tour-form" action="/admin/tours" method="POST" enctype="multipart/form-data">
<div class="form-group">
<label>Category</label>
<select name="category" class="form-control">
<option value="tour">Tour</option>
<option value="surf">Surf</option>
<option value="volcano">Volcano</option>
</select>
</div>
<div class="form-group">
<label>Title (English)</label>
<input type="text" name="title_en" class="form-control" required>
</div>
<div class="form-group">
<label>Title (Spanish)</label>
<input type="text" name="title_es" class="form-control" required>
</div>
<div class="form-group">
<label>Description (English)</label>
<textarea name="desc_en" class="form-control" required></textarea>
</div>
<div class="form-group">
<label>Description (Spanish)</label>
<textarea name="desc_es" class="form-control" required></textarea>
</div>
<div class="form-group">
<label>Price ($)</label>
<input type="text" name="price" class="form-control" required>
</div>
<div class="form-group">
<label>Image URL (Leave blank if you want to upload a file)</label>
<input type="text" name="image_url" class="form-control">
</div>
<div class="form-group">
<label>Upload Image</label>
<input type="file" name="image_upload" class="form-control" accept="image/*">
</div>
<div class="form-group" style="margin-top: 2rem;">
<button type="submit" id="tour-submit-btn" class="btn btn-primary">Add Tour</button>
<button type="button" id="tour-cancel-btn" class="btn" style="display:none; background: #555; margin-left:1rem; color: #FFF;" onclick="cancelEdit()">Cancel</button>
</div>
</form>
</div>
<!-- List Tours -->
<div class="glass-panel">
<h2 style="color: var(--primary-color); margin-bottom: 1.5rem;">Current Activities</h2>
<div style="max-height: 600px; overflow-y: auto;">
<table class="admin-table">
<thead>
<tr>
<th>Category</th>
<th>Title</th>
<th>Price</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{% for tour in tours %}
<tr>
<td>{{ tour.category }}</td>
<td>{{ tour.title_en }}</td>
<td>${{ tour.price }}</td>
<td>
<button type="button" class="edit-tour-btn" style="background:none; border:none; color:var(--primary-color); font-weight: bold; cursor:pointer; margin-right: 15px;"
data-id="{{ tour.id }}"
data-category="{{ tour.category }}"
data-title-en="{{ tour.title_en }}"
data-title-es="{{ tour.title_es }}"
data-desc-en="{{ tour.desc_en }}"
data-desc-es="{{ tour.desc_es }}"
data-price="{{ tour.price }}"
data-image="{{ tour.image_url }}">
Edit
</button>
<form action="/admin/tours/delete/{{ tour.id }}" method="POST" style="display:inline;">
<button type="submit" style="background:none; border:none; color:#ff4444; cursor:pointer;" onclick="return confirm('Are you sure you want to delete this?')">Delete</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Settings Tab -->
<div id="tab-settings" class="tab-content" style="display: none;">
<div class="glass-panel" style="padding: 2rem;">
<h2 style="color: var(--primary-color); margin-bottom: 1.5rem;">System & API Settings</h2>
<form action="/admin/settings" method="POST" enctype="multipart/form-data">
<div class="admin-grid">
<div>
<h3 style="color: var(--secondary-color); margin: 0 0 1rem;">General</h3>
<div class="form-group">
<label>Agency Name</label>
<input type="text" name="agency_name" class="form-control" value="{{ settings.get('agency_name', '') }}">
</div>
<div class="form-group">
<label>Agency Email (Sender/Receiver)</label>
<input type="text" name="agency_email" class="form-control" value="{{ settings.get('agency_email', '') }}">
</div>
<div class="form-group">
<label>Logo URL (Leave blank to use uploaded image)</label>
<input type="text" name="logo_url" class="form-control" value="{{ settings.get('logo_url', '') }}">
</div>
<div class="form-group">
<label>Upload Logo</label>
<input type="file" name="logo_upload" class="form-control" accept="image/*">
</div>
<h3 style="color: var(--secondary-color); margin: 1.5rem 0 1rem;">Hero Video</h3>
<div class="form-group">
<label>Video URL (Leave blank to upload a file)</label>
<input type="text" name="hero_video_url" class="form-control" value="{{ settings.get('hero_video_url', '') }}">
</div>
<div class="form-group">
<label>Upload Video (.mp4)</label>
<input type="file" name="hero_video_upload" class="form-control" accept="video/mp4">
</div>
<h3 style="color: var(--secondary-color); margin: 1.5rem 0 1rem;">Mailtrap SMTP</h3>
<div class="form-group">
<label>Host</label>
<input type="text" name="mailtrap_host" class="form-control" value="{{ settings.get('mailtrap_host', '') }}">
</div>
<div class="form-group">
<label>Port</label>
<input type="text" name="mailtrap_port" class="form-control" value="{{ settings.get('mailtrap_port', '') }}">
</div>
<div class="form-group">
<label>Username</label>
<input type="text" name="mailtrap_user" class="form-control" value="{{ settings.get('mailtrap_user', '') }}">
</div>
<div class="form-group">
<label>Password</label>
<input type="password" name="mailtrap_password" class="form-control" value="{{ settings.get('mailtrap_password', '') }}">
</div>
</div>
<div>
<h3 style="color: var(--secondary-color); margin: 0 0 1rem;">Cloudflare Turnstile</h3>
<div class="form-group">
<label>Site Key</label>
<input type="text" name="turnstile_site_key" class="form-control" value="{{ settings.get('turnstile_site_key', '') }}">
</div>
<div class="form-group">
<label>Secret Key</label>
<input type="password" name="turnstile_secret_key" class="form-control" value="{{ settings.get('turnstile_secret_key', '') }}">
</div>
<h3 style="color: var(--secondary-color); margin: 1.5rem 0 1rem;">Retell AI Integration</h3>
<div class="form-group">
<label>API Key</label>
<input type="password" name="retell_api_key" class="form-control" value="{{ settings.get('retell_api_key', '') }}">
</div>
<div class="form-group">
<label>Phone Number</label>
<input type="text" name="retell_number" class="form-control" value="{{ settings.get('retell_number', '') }}">
</div>
<div class="form-group checkbox-wrap" style="color: var(--text-light); margin-bottom: 1rem;">
<label>
<input type="checkbox" name="retell_enabled" {% if settings.get('retell_enabled') == 'true' %}checked{% endif %}>
Enable Retell AI Checkbox in Public Forms
</label>
</div>
<div class="form-group">
<label>Agent ID</label>
<input type="text" name="retell_agent_id" class="form-control" value="{{ settings.get('retell_agent_id', '') }}">
</div>
</div>
</div>
<div style="margin-top: 2rem;">
<button type="submit" class="btn btn-primary">Save Settings</button>
</div>
</form>
</div>
</div>
</div>
<script>
function openTab(evt, tabId) {
var i, tabcontent, tablinks;
tabcontent = document.getElementsByClassName("tab-content");
for (i = 0; i < tabcontent.length; i++) {
tabcontent[i].style.display = "none";
}
tablinks = document.getElementsByClassName("tab-btn");
for (i = 0; i < tablinks.length; i++) {
tablinks[i].className = tablinks[i].className.replace(" active", "");
}
document.getElementById(tabId).style.display = "block";
evt.currentTarget.className += " active";
}
// Edit Tour Functionality
document.querySelectorAll('.edit-tour-btn').forEach(button => {
button.addEventListener('click', function() {
var form = document.getElementById('tour-form');
document.getElementById('tour-form-title').innerText = "Edit Activity / Tour";
form.action = "/admin/tours/update/" + this.getAttribute('data-id');
form.querySelector('[name="category"]').value = this.getAttribute('data-category');
form.querySelector('[name="title_en"]').value = this.getAttribute('data-title-en');
form.querySelector('[name="title_es"]').value = this.getAttribute('data-title-es');
form.querySelector('[name="desc_en"]').value = this.getAttribute('data-desc-en');
form.querySelector('[name="desc_es"]').value = this.getAttribute('data-desc-es');
form.querySelector('[name="price"]').value = this.getAttribute('data-price');
form.querySelector('[name="image_url"]').value = this.getAttribute('data-image');
form.querySelector('[name="image_upload"]').value = ""; // Clear file input on edit
document.getElementById('tour-submit-btn').innerText = "Update Tour";
document.getElementById('tour-cancel-btn').style.display = "inline-block";
document.getElementById('tour-form').scrollIntoView({ behavior: 'smooth' });
});
});
function cancelEdit() {
var form = document.getElementById('tour-form');
document.getElementById('tour-form-title').innerText = "Add Activity / Tour";
form.action = "/admin/tours";
form.reset();
document.getElementById('tour-submit-btn').innerText = "Add Tour";
document.getElementById('tour-cancel-btn').style.display = "none";
}
</script>
{% endblock %}

102
templates/detail.html Normal file
View File

@@ -0,0 +1,102 @@
{% extends "base.html" %}
{% block content %}
<div class="admin-container" style="max-width: 900px; margin: 0 auto;">
<a href="/?lang={{ lang }}" class="btn" style="background: rgba(255,255,255,0.1); color: #FFF; margin-bottom: 2rem; padding: 0.5rem 1.5rem; font-size: 0.9rem;">
&larr; {{ 'Volver' if lang == 'es' else 'Back' }}
</a>
<div class="glass-panel" style="padding: 0; overflow: hidden;">
{% if tour.image_url %}
<img src="{{ tour.image_url }}" alt="{{ tour.title_en }}" style="width: 100%; height: 400px; object-fit: cover; border-bottom: 3px solid var(--primary-color);">
{% endif %}
<div style="padding: 3rem;">
<div style="display: flex; justify-content: space-between; align-items: flex-start; gap: 2rem; flex-wrap: wrap;">
<div>
<span style="background: var(--primary-color); color: var(--dark-bg); padding: 0.3rem 1rem; border-radius: 20px; font-weight: 800; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 1px;">
{{ tour.category }}
</span>
<h1 style="color: var(--primary-color); font-size: 3rem; margin: 1rem 0;">
{{ tour.title_es if lang == 'es' else tour.title_en }}
</h1>
</div>
<div style="text-align: right;">
<span style="display: block; font-size: 1rem; color: #BBB;">{{ 'Precio' if lang == 'es' else 'Price' }}</span>
<span style="font-size: 2.5rem; font-weight: 800; color: var(--secondary-color);">${{ tour.price }}</span>
</div>
</div>
<div style="margin: 2rem 0; padding-top: 2rem; border-top: 1px solid rgba(255,255,255,0.1);">
<h3 style="color: #FFF; margin-bottom: 1rem;">{{ 'Descripción' if lang == 'es' else 'Description' }}</h3>
<p style="color: #CCC; font-size: 1.1rem; line-height: 1.8;">
{{ tour.desc_es if lang == 'es' else tour.desc_en }}
</p>
</div>
<div style="margin-top: 4rem;">
<h3 style="color: var(--primary-color); text-align: center; margin-bottom: 2rem; font-size: 2rem;">
{{ 'Reserva Este Tour' if lang == 'es' else 'Book This Tour' }}
</h3>
<form action="/submit?lang={{ lang }}" method="POST">
<div class="form-group">
<label>{{ 'Nombre' if lang == 'es' else 'Full Name' }}</label>
<input type="text" name="name" class="form-control" required>
</div>
<div class="form-group">
<label>{{ 'Correo Electrónico' if lang == 'es' else 'Email' }}</label>
<input type="email" name="email" class="form-control" required>
</div>
<div class="form-group">
<label>{{ 'Teléfono' if lang == 'es' else 'Phone' }}</label>
<input type="tel" name="phone" class="form-control" required>
</div>
<div class="form-group">
<label>{{ 'Interesado en' if lang == 'es' else 'Interested In' }}</label>
<select name="interested_in" class="form-control">
<option value="tour" {% if tour.category == 'tour' %}selected{% endif %}>Tours</option>
<option value="surf" {% if tour.category == 'surf' %}selected{% endif %}>Surf</option>
<option value="volcano" {% if tour.category == 'volcano' %}selected{% endif %}>Volcanoes</option>
</select>
</div>
<div class="form-group">
<label>{{ 'Mensaje' if lang == 'es' else 'Message' }}</label>
<textarea name="message" class="form-control" rows="4">{{ 'Me gustaría reservar el tour: ' ~ tour.title_es if lang == 'es' else 'I would like to book the tour: ' ~ tour.title_en }}</textarea>
</div>
<!-- Checkbox Normal -->
<label class="checkbox-wrap" style="color: #BBB;">
<input type="checkbox" required>
{{ 'Acepto los términos y condiciones' if lang == 'es' else 'I accept the terms and conditions' }}
</label>
<!-- Checkbox Retell AI -->
{% if settings.get('retell_enabled') == 'true' %}
<label class="checkbox-wrap" style="color: #BBB;">
<input type="checkbox" name="wants_retell_ai" value="true">
{{ 'Hablar con un Agente IA para coordinar detalles' if lang == 'es' else 'Speak with an AI Agent to coordinate details' }}
</label>
{% endif %}
<div class="form-group" style="margin-top: 1rem;">
{% if turnstile_site_key %}
<div class="cf-turnstile" data-sitekey="{{ turnstile_site_key }}"></div>
{% else %}
<i style="color: #888;">(Cloudflare Turnstile disabled, missing site key)</i>
{% endif %}
</div>
<button type="submit" class="btn btn-primary" style="width: 100%; border-radius: 8px;">
{{ 'Enviar Solicitud' if lang == 'es' else 'Submit Booking Request' }}
</button>
</form>
</div>
</div>
</div>
</div>
{% endblock %}

156
templates/index.html Normal file
View File

@@ -0,0 +1,156 @@
{% extends "base.html" %}
{% block content %}
<header class="hero">
{% if settings.get('hero_video_url') %}
<video class="hero-video" autoplay muted loop playsinline>
<source src="{{ settings.get('hero_video_url') }}" type="video/mp4">
</video>
{% endif %}
<div class="hero-overlay"></div>
<div class="hero-content">
<h1>{{ 'Descubre Guatemala' if lang == 'es' else 'Discover Guatemala' }}</h1>
<p>{{ 'La tierra de la eterna primavera, antiguas ruinas mayas y playas de clase mundial.' if lang == 'es' else 'The land of eternal spring, ancient Mayan ruins, and world-class surfing.' }}</p>
<a href="#contact" class="btn btn-primary">{{ 'Reserva Ahora' if lang == 'es' else 'Book Now' }}</a>
</div>
</header>
{% if request.args.get("submitted") %}
<div style="text-align: center; color: var(--primary-color); padding: 1rem; border: 1px solid var(--primary-color); margin: 2rem auto; width: 60%; font-weight: bold;">
{{ '¡Gracias! Nos pondremos en contacto pronto.' if lang == 'es' else 'Thank you! We will be in touch soon.' }}
</div>
{% endif %}
<section id="tours">
<h2 class="section-title">{{ 'Nuestros Tours' if lang == 'es' else 'Our Tours' }}</h2>
<div class="grid">
{% for tour in tours %}
<div class="card glass-panel">
{% if tour.image_url %}
<img src="{{ tour.image_url }}" alt="{{ tour.title_en }}" class="card-img">
{% endif %}
<div class="card-content">
<h3>{{ tour.title_es if lang == 'es' else tour.title_en }}</h3>
<p>{{ tour.desc_es if lang == 'es' else tour.desc_en }}</p>
<div style="display: flex; justify-content: space-between; align-items: center; margin-top: 1rem;">
<div class="price">${{ tour.price }}</div>
<a href="/tour/{{ tour.id }}?lang={{ lang }}" class="btn btn-primary" style="padding: 0.5rem 1rem; font-size: 0.8rem;">{{ 'Detalles' if lang == 'es' else 'Details' }}</a>
</div>
</div>
</div>
{% else %}
<p style="text-align:center; width:100%;">{{ 'Pronto...' if lang == 'es' else 'Coming soon...' }}</p>
{% endfor %}
</div>
</section>
<section id="surf">
<h2 class="section-title">{{ 'Surf en El Paredón' if lang == 'es' else 'Surf in El Paredón' }}</h2>
<div class="grid">
{% for tour in surfs %}
<div class="card glass-panel">
{% if tour.image_url %}
<img src="{{ tour.image_url }}" alt="{{ tour.title_en }}" class="card-img">
{% endif %}
<div class="card-content">
<h3>{{ tour.title_es if lang == 'es' else tour.title_en }}</h3>
<p>{{ tour.desc_es if lang == 'es' else tour.desc_en }}</p>
<div style="display: flex; justify-content: space-between; align-items: center; margin-top: 1rem;">
<div class="price">${{ tour.price }}</div>
<a href="/tour/{{ tour.id }}?lang={{ lang }}" class="btn btn-primary" style="padding: 0.5rem 1rem; font-size: 0.8rem;">{{ 'Detalles' if lang == 'es' else 'Details' }}</a>
</div>
</div>
</div>
{% else %}
<p style="text-align:center; width:100%;">{{ 'Pronto...' if lang == 'es' else 'Coming soon...' }}</p>
{% endfor %}
</div>
</section>
<section id="volcanoes">
<h2 class="section-title">{{ 'Volcanes Activos' if lang == 'es' else 'Active Volcanoes' }}</h2>
<div class="grid">
{% for tour in volcanoes %}
<div class="card glass-panel">
{% if tour.image_url %}
<img src="{{ tour.image_url }}" alt="{{ tour.title_en }}" class="card-img">
{% endif %}
<div class="card-content">
<h3>{{ tour.title_es if lang == 'es' else tour.title_en }}</h3>
<p>{{ tour.desc_es if lang == 'es' else tour.desc_en }}</p>
<div style="display: flex; justify-content: space-between; align-items: center; margin-top: 1rem;">
<div class="price">${{ tour.price }}</div>
<a href="/tour/{{ tour.id }}?lang={{ lang }}" class="btn btn-primary" style="padding: 0.5rem 1rem; font-size: 0.8rem;">{{ 'Detalles' if lang == 'es' else 'Details' }}</a>
</div>
</div>
</div>
{% else %}
<p style="text-align:center; width:100%;">{{ 'Pronto...' if lang == 'es' else 'Coming soon...' }}</p>
{% endfor %}
</div>
</section>
<section id="contact" class="contact-section">
<div class="glass-panel contact-form">
<h2 style="color: var(--primary-color); margin-bottom: 2rem; font-size: 2rem; text-align: center;">
{{ 'Comienza tu Viaje' if lang == 'es' else 'Start Your Journey' }}
</h2>
<form action="/submit?lang={{ lang }}" method="POST">
<div class="form-group">
<label>{{ 'Nombre' if lang == 'es' else 'Full Name' }}</label>
<input type="text" name="name" class="form-control" required>
</div>
<div class="form-group">
<label>{{ 'Correo Electrónico' if lang == 'es' else 'Email' }}</label>
<input type="email" name="email" class="form-control" required>
</div>
<div class="form-group">
<label>{{ 'Teléfono' if lang == 'es' else 'Phone' }}</label>
<input type="tel" name="phone" class="form-control" required>
</div>
<div class="form-group">
<label>{{ 'Interesado en' if lang == 'es' else 'Interested In' }}</label>
<select name="interested_in" class="form-control">
<option value="tour">Tours</option>
<option value="surf">Surf</option>
<option value="volcano">Volcanoes</option>
</select>
</div>
<div class="form-group">
<label>{{ 'Mensaje' if lang == 'es' else 'Message' }}</label>
<textarea name="message" class="form-control" rows="4"></textarea>
</div>
<!-- Checkbox Normal -->
<label class="checkbox-wrap" style="color: #BBB;">
<input type="checkbox" required>
{{ 'Acepto los términos y condiciones' if lang == 'es' else 'I accept the terms and conditions' }}
</label>
<!-- Checkbox Retell AI -->
{% if settings.get('retell_enabled') == 'true' %}
<label class="checkbox-wrap" style="color: #BBB;">
<input type="checkbox" name="wants_retell_ai" value="true">
{{ 'Hablar con un Agente IA para coordinar' if lang == 'es' else 'Speak with an AI Agent to coordinate' }}
</label>
{% endif %}
<div class="form-group" style="margin-top: 1rem;">
{% if turnstile_site_key %}
<div class="cf-turnstile" data-sitekey="{{ turnstile_site_key }}"></div>
{% else %}
<i style="color: #888;">(Cloudflare Turnstile disabled, missing site key)</i>
{% endif %}
</div>
<button type="submit" class="btn btn-primary" style="width: 100%; border-radius: 8px;">
{{ 'Enviar Solicitud' if lang == 'es' else 'Submit Request' }}
</button>
</form>
</div>
</section>
{% endblock %}

247
venv/bin/Activate.ps1 Normal file
View File

@@ -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
venv/bin/activate Normal file
View File

@@ -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/eduardo/Desktop/GTravel/venv)
else
# use the path as-is
export VIRTUAL_ENV=/home/eduardo/Desktop/GTravel/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
venv/bin/activate.csh Normal file
View File

@@ -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/eduardo/Desktop/GTravel/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
venv/bin/activate.fish Normal file
View File

@@ -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/eduardo/Desktop/GTravel/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
venv/bin/dotenv Executable file
View File

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

8
venv/bin/fastapi Executable file
View File

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

8
venv/bin/flask Executable file
View File

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

8
venv/bin/gunicorn Executable file
View File

@@ -0,0 +1,8 @@
#!/home/eduardo/Desktop/GTravel/venv/bin/python3
# -*- coding: utf-8 -*-
import re
import sys
from gunicorn.app.wsgiapp import run
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(run())

8
venv/bin/gunicornc Executable file
View File

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

8
venv/bin/normalizer Executable file
View File

@@ -0,0 +1,8 @@
#!/home/eduardo/Desktop/GTravel/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
venv/bin/pip Executable file
View File

@@ -0,0 +1,8 @@
#!/home/eduardo/Desktop/GTravel/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
venv/bin/pip3 Executable file
View File

@@ -0,0 +1,8 @@
#!/home/eduardo/Desktop/GTravel/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
venv/bin/pip3.12 Executable file
View File

@@ -0,0 +1,8 @@
#!/home/eduardo/Desktop/GTravel/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
venv/bin/python Symbolic link
View File

@@ -0,0 +1 @@
python3

1
venv/bin/python3 Symbolic link
View File

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

1
venv/bin/python3.12 Symbolic link
View File

@@ -0,0 +1 @@
python3

8
venv/bin/uvicorn Executable file
View File

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

View File

@@ -0,0 +1,164 @@
/* -*- indent-tabs-mode: nil; tab-width: 4; -*- */
/* Greenlet object interface */
#ifndef Py_GREENLETOBJECT_H
#define Py_GREENLETOBJECT_H
#include <Python.h>
#ifdef __cplusplus
extern "C" {
#endif
/* This is deprecated and undocumented. It does not change. */
#define GREENLET_VERSION "1.0.0"
#ifndef GREENLET_MODULE
#define implementation_ptr_t void*
#endif
typedef struct _greenlet {
PyObject_HEAD
PyObject* weakreflist;
PyObject* dict;
implementation_ptr_t pimpl;
} PyGreenlet;
#define PyGreenlet_Check(op) (op && PyObject_TypeCheck(op, &PyGreenlet_Type))
/* C API functions */
/* Total number of symbols that are exported */
#define PyGreenlet_API_pointers 12
#define PyGreenlet_Type_NUM 0
#define PyExc_GreenletError_NUM 1
#define PyExc_GreenletExit_NUM 2
#define PyGreenlet_New_NUM 3
#define PyGreenlet_GetCurrent_NUM 4
#define PyGreenlet_Throw_NUM 5
#define PyGreenlet_Switch_NUM 6
#define PyGreenlet_SetParent_NUM 7
#define PyGreenlet_MAIN_NUM 8
#define PyGreenlet_STARTED_NUM 9
#define PyGreenlet_ACTIVE_NUM 10
#define PyGreenlet_GET_PARENT_NUM 11
#ifndef GREENLET_MODULE
/* This section is used by modules that uses the greenlet C API */
static void** _PyGreenlet_API = NULL;
# define PyGreenlet_Type \
(*(PyTypeObject*)_PyGreenlet_API[PyGreenlet_Type_NUM])
# define PyExc_GreenletError \
((PyObject*)_PyGreenlet_API[PyExc_GreenletError_NUM])
# define PyExc_GreenletExit \
((PyObject*)_PyGreenlet_API[PyExc_GreenletExit_NUM])
/*
* PyGreenlet_New(PyObject *args)
*
* greenlet.greenlet(run, parent=None)
*/
# define PyGreenlet_New \
(*(PyGreenlet * (*)(PyObject * run, PyGreenlet * parent)) \
_PyGreenlet_API[PyGreenlet_New_NUM])
/*
* PyGreenlet_GetCurrent(void)
*
* greenlet.getcurrent()
*/
# define PyGreenlet_GetCurrent \
(*(PyGreenlet * (*)(void)) _PyGreenlet_API[PyGreenlet_GetCurrent_NUM])
/*
* PyGreenlet_Throw(
* PyGreenlet *greenlet,
* PyObject *typ,
* PyObject *val,
* PyObject *tb)
*
* g.throw(...)
*/
# define PyGreenlet_Throw \
(*(PyObject * (*)(PyGreenlet * self, \
PyObject * typ, \
PyObject * val, \
PyObject * tb)) \
_PyGreenlet_API[PyGreenlet_Throw_NUM])
/*
* PyGreenlet_Switch(PyGreenlet *greenlet, PyObject *args)
*
* g.switch(*args, **kwargs)
*/
# define PyGreenlet_Switch \
(*(PyObject * \
(*)(PyGreenlet * greenlet, PyObject * args, PyObject * kwargs)) \
_PyGreenlet_API[PyGreenlet_Switch_NUM])
/*
* PyGreenlet_SetParent(PyObject *greenlet, PyObject *new_parent)
*
* g.parent = new_parent
*/
# define PyGreenlet_SetParent \
(*(int (*)(PyGreenlet * greenlet, PyGreenlet * nparent)) \
_PyGreenlet_API[PyGreenlet_SetParent_NUM])
/*
* PyGreenlet_GetParent(PyObject* greenlet)
*
* return greenlet.parent;
*
* This could return NULL even if there is no exception active.
* If it does not return NULL, you are responsible for decrementing the
* reference count.
*/
# define PyGreenlet_GetParent \
(*(PyGreenlet* (*)(PyGreenlet*)) \
_PyGreenlet_API[PyGreenlet_GET_PARENT_NUM])
/*
* deprecated, undocumented alias.
*/
# define PyGreenlet_GET_PARENT PyGreenlet_GetParent
# define PyGreenlet_MAIN \
(*(int (*)(PyGreenlet*)) \
_PyGreenlet_API[PyGreenlet_MAIN_NUM])
# define PyGreenlet_STARTED \
(*(int (*)(PyGreenlet*)) \
_PyGreenlet_API[PyGreenlet_STARTED_NUM])
# define PyGreenlet_ACTIVE \
(*(int (*)(PyGreenlet*)) \
_PyGreenlet_API[PyGreenlet_ACTIVE_NUM])
/* Macro that imports greenlet and initializes C API */
/* NOTE: This has actually moved to ``greenlet._greenlet._C_API``, but we
keep the older definition to be sure older code that might have a copy of
the header still works. */
# define PyGreenlet_Import() \
{ \
_PyGreenlet_API = (void**)PyCapsule_Import("greenlet._C_API", 0); \
}
#endif /* GREENLET_MODULE */
#ifdef __cplusplus
}
#endif
#endif /* !Py_GREENLETOBJECT_H */

View File

@@ -0,0 +1,82 @@
Metadata-Version: 2.1
Name: a2wsgi
Version: 1.10.10
Summary: Convert WSGI app to ASGI app or ASGI app to WSGI app.
Author-Email: abersheeran <me@abersheeran.com>
License: Apache-2.0
Classifier: Programming Language :: Python :: 3
Project-URL: homepage, https://github.com/abersheeran/a2wsgi
Project-URL: repository, https://github.com/abersheeran/a2wsgi
Requires-Python: >=3.8.0
Requires-Dist: typing_extensions; python_version < "3.11"
Description-Content-Type: text/markdown
# a2wsgi
Convert WSGI app to ASGI app or ASGI app to WSGI app.
Pure Python. Only depend on the standard library.
Compared with other converters, the advantage is that a2wsgi will not accumulate the requested content or response content in the memory, so you don't have to worry about the memory limit caused by a2wsgi. This problem exists in converters implemented by uvicorn/startlette or hypercorn.
## Install
```
pip install a2wsgi
```
## How to use
### `WSGIMiddleware`
Convert WSGI app to ASGI app:
```python
from a2wsgi import WSGIMiddleware
ASGI_APP = WSGIMiddleware(WSGI_APP)
```
WSGIMiddleware executes WSGI applications with a thread pool of up to 10 threads by default. If you want to increase or decrease this number, just like `WSGIMiddleware(..., workers=15)`.
WSGIMiddleware utilizes a queue to direct traffic from the WSGI App to the client. To adjust the queue size, simply specify the send_queue_size parameter (default to `10`) during initialization, like so: WSGIMiddleware(..., send_queue_size=15). This enable developers to balance memory usage and application responsiveness.
### `ASGIMiddleware`
Convert ASGI app to WSGI app:
```python
from a2wsgi import ASGIMiddleware
WSGI_APP = ASGIMiddleware(ASGI_APP)
```
`ASGIMiddleware` will wait for the ASGI application's Background Task to complete before returning the last null byte. But sometimes you may not want to wait indefinitely for the execution of the Background Task of the ASGI application, then you only need to give the parameter `ASGIMiddleware(..., wait_time=5.0)`, after the time exceeds, the ASGI task corresponding to the request will be tried to cancel, and the last null byte will be returned.
You can also specify your own event loop through the `loop` parameter instead of the default event loop. Like `ASGIMiddleware(..., loop=faster_loop)`
### Access the original `Scope`/`Environ`
Sometimes you may need to access the original WSGI Environ in the ASGI application, just use `scope["wsgi_environ"]`; it is also easy to access the ASGI Scope in the WSGI Application, use `environ["asgi.scope"]`.
## Benchmark
Run `pytest ./benchmark.py -s` to compare the performance of `a2wsgi` and `uvicorn.middleware.wsgi.WSGIMiddleware` / `asgiref.wsgi.WsgiToAsgi`.
## Why a2wsgi
### Convert WSGI app to ASGI app
You can convert an existing WSGI project to an ASGI project to make it easier to migrate from WSGI applications to ASGI applications.
### Convert ASGI app to WSGI app
There is a lot of support for WSGI. Converting ASGI to WSGI, you will be able to use many existing services to deploy ASGI applications.
## Compatibility list
This list quickly demonstrates the compatibility of some common frameworks for users who are unfamiliar with the WSGI and ASGI protocols.
- WSGI: [Django(wsgi)](https://docs.djangoproject.com/en/3.0/howto/deployment/wsgi/)/[Kuí(wsgi)](https://kui.aber.sh/wsgi/)/[Falcon(wsgi)](https://falcon.readthedocs.io/en/stable/api/app.html#wsgi-app)/[Pyramid](https://trypyramid.com/)/[Bottle](https://bottlepy.org/)/[Flask](https://flask.palletsprojects.com/)
- ASGI: [Django(asgi)](https://docs.djangoproject.com/en/3.0/howto/deployment/asgi/)/[Kuí(asgi)](https://kui.aber.sh/asgi/)/[Falcon(asgi)](https://falcon.readthedocs.io/en/stable/api/app.html#asgi-app)/[Starlette](https://www.starlette.io/)/[FastAPI](https://fastapi.tiangolo.com/)/[Sanic](https://sanic.readthedocs.io/en/stable/)/[Quart](https://pgjones.gitlab.io/quart/)
- **Unsupport**: [aiohttp](https://docs.aiohttp.org/en/stable/)

View File

@@ -0,0 +1,18 @@
a2wsgi-1.10.10.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
a2wsgi-1.10.10.dist-info/METADATA,sha256=mJSvq_I2soKNUnMWf1pZgxtUFf0GGSf_0wwxQprEgDg,4016
a2wsgi-1.10.10.dist-info/RECORD,,
a2wsgi-1.10.10.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
a2wsgi-1.10.10.dist-info/WHEEL,sha256=tSfRZzRHthuv7vxpI4aehrdN9scLjk-dCJkPLzkHxGg,90
a2wsgi-1.10.10.dist-info/entry_points.txt,sha256=6OYgBcLyFCUgeqLgnvMyOJxPCWzgy7se4rLPKtNonMs,34
a2wsgi-1.10.10.dist-info/licenses/LICENSE,sha256=xacnkn1wNfIpygUKxURVJVv8YmVdFUbRJvAcC1X67xk,11341
a2wsgi/__init__.py,sha256=af4YlqrcrFpexdL7TmRrd37256F5IOThrway8X3YuKU,185
a2wsgi/__pycache__/__init__.cpython-312.pyc,,
a2wsgi/__pycache__/asgi.cpython-312.pyc,,
a2wsgi/__pycache__/asgi_typing.cpython-312.pyc,,
a2wsgi/__pycache__/wsgi.cpython-312.pyc,,
a2wsgi/__pycache__/wsgi_typing.cpython-312.pyc,,
a2wsgi/asgi.py,sha256=CZkl07VZ--p5t03qCu5QnugMNM6XvIS_yAOtw9zGVqI,10363
a2wsgi/asgi_typing.py,sha256=I8HtcFfZIK-hZrvimhWmK4PuOjyI6RtZf81qRw9hzxU,4118
a2wsgi/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
a2wsgi/wsgi.py,sha256=N8CJm0WU8_LdhwURXmrGfIn7CKCHAJdY5wNyKDreLVw,9366
a2wsgi/wsgi_typing.py,sha256=-vIccby9jhWPkR0085WXZRCSjj6zJTHM4zTMioDaE84,7904

View File

@@ -0,0 +1,4 @@
Wheel-Version: 1.0
Generator: pdm-backend (2.4.4)
Root-Is-Purelib: true
Tag: py3-none-any

View File

@@ -0,0 +1,4 @@
[console_scripts]
[gui_scripts]

View File

@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2022 abersheeran
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@@ -0,0 +1,8 @@
from .asgi import ASGIMiddleware
from .wsgi import WSGIMiddleware
VERSION = (1, 10, 10)
__version__: str = ".".join(map(str, VERSION))
__all__ = ("WSGIMiddleware", "ASGIMiddleware")

View File

@@ -0,0 +1,303 @@
import asyncio
import collections
import threading
from http import HTTPStatus
from io import BytesIO
from typing import Any, Coroutine, Deque, Iterable, Optional, TypeVar
from typing import cast as typing_cast
from .asgi_typing import HTTPScope, ASGIApp, ReceiveEvent, SendEvent
from .wsgi_typing import Environ, StartResponse, IterableChunks
T = TypeVar("T")
class defaultdict(dict):
def __init__(self, default_factory, *args, **kwargs) -> None:
self.default_factory = default_factory
super().__init__(*args, **kwargs)
def __missing__(self, key):
return self.default_factory(key)
StatusStringMapping = defaultdict(
lambda status: f"{status} Unknown Status Code",
{status.value: f"{status.value} {status.phrase}" for status in HTTPStatus},
)
class AsyncEvent:
def __init__(self, loop: asyncio.AbstractEventLoop) -> None:
self.loop = loop
self.__waiters: Deque[asyncio.Future] = collections.deque()
self.__nowait = False
def _set(self, message: Any) -> None:
for future in filter(lambda f: not f.done(), self.__waiters):
future.set_result(message)
def set(self, message: Any) -> None:
self.loop.call_soon_threadsafe(self._set, message)
async def wait(self) -> Any:
if self.__nowait:
return None
future = self.loop.create_future()
self.__waiters.append(future)
try:
result = await future
return result
finally:
self.__waiters.remove(future)
def set_nowait(self) -> None:
self.__nowait = True
class SyncEvent:
def __init__(self) -> None:
self.__write_event = threading.Event()
self.__message: Any = None
def set(self, message: Any) -> None:
self.__message = message
self.__write_event.set()
def wait(self) -> Any:
self.__write_event.wait()
self.__write_event.clear()
message, self.__message = self.__message, None
return message
def build_scope(environ: Environ) -> HTTPScope:
headers = [
(
(key[5:] if key.startswith("HTTP_") else key)
.lower()
.replace("_", "-")
.encode("latin-1"),
value.encode("latin-1"), # type: ignore
)
for key, value in environ.items()
if (
key.startswith("HTTP_")
and key not in ("HTTP_CONTENT_TYPE", "HTTP_CONTENT_LENGTH")
)
or key in ("CONTENT_TYPE", "CONTENT_LENGTH")
]
root_path = environ.get("SCRIPT_NAME", "").encode("latin1").decode("utf8")
path = root_path + environ.get("PATH_INFO", "").encode("latin1").decode("utf8")
scope: HTTPScope = {
"wsgi_environ": environ, # type: ignore a2wsgi
"type": "http",
"asgi": {"version": "3.0", "spec_version": "2.5"},
"http_version": environ.get("SERVER_PROTOCOL", "http/1.0").split("/")[1],
"method": environ["REQUEST_METHOD"],
"scheme": environ.get("wsgi.url_scheme", "http"),
"path": path,
"query_string": environ.get("QUERY_STRING", "").encode("ascii"),
"root_path": root_path,
"server": (environ["SERVER_NAME"], int(environ["SERVER_PORT"])),
"headers": headers,
"extensions": {},
}
if environ.get("REMOTE_ADDR") and environ.get("REMOTE_PORT"):
client = (environ.get("REMOTE_ADDR", ""), int(environ.get("REMOTE_PORT", "0")))
scope["client"] = client
return scope
class ASGIMiddleware:
"""
Convert ASGIApp to WSGIApp.
wait_time: After the http response ends, the maximum time to wait for the ASGI app to run.
"""
def __init__(
self,
app: ASGIApp,
wait_time: Optional[float] = None,
loop: Optional[asyncio.AbstractEventLoop] = None,
) -> None:
self.app = app
if loop is None:
loop = asyncio.new_event_loop()
loop_threading = threading.Thread(target=loop.run_forever, daemon=True)
loop_threading.start()
self.loop = loop
self.wait_time = wait_time
def __call__(
self, environ: Environ, start_response: StartResponse
) -> Iterable[bytes]:
return ASGIResponder(self.app, self.loop, self.wait_time)(
environ, start_response
)
class ASGIResponder:
def __init__(
self,
app: ASGIApp,
loop: asyncio.AbstractEventLoop,
wait_time: Optional[float] = None,
) -> None:
self.app = app
self.loop = loop
self.wait_time = wait_time
self.sync_event = SyncEvent()
self.sync_event_set_lock: asyncio.Lock
self.receive_event = AsyncEvent(loop)
self.send_event = AsyncEvent(loop)
def _init_async_lock():
self.sync_event_set_lock = asyncio.Lock()
loop.call_soon_threadsafe(_init_async_lock)
self.asgi_done = threading.Event()
self.wsgi_should_stop: bool = False
async def asgi_receive(self) -> ReceiveEvent:
await self.sync_event_set_lock.acquire()
self.sync_event.set({"type": "receive"})
return await self.receive_event.wait()
async def asgi_send(self, message: SendEvent) -> None:
await self.sync_event_set_lock.acquire()
self.sync_event.set(message)
await self.send_event.wait()
def asgi_done_callback(self, future: asyncio.Future) -> None:
try:
exception = future.exception()
except asyncio.CancelledError:
pass
else:
if exception is not None:
task = asyncio.create_task(self.sync_event_set_lock.acquire())
task.add_done_callback(
lambda _: self.sync_event.set(
{
"type": "a2wsgi.error",
"exception": (
type(exception),
exception,
exception.__traceback__,
),
}
)
)
finally:
self.asgi_done.set()
async def start_asgi_app(self, environ: Environ) -> asyncio.Task:
run_asgi: asyncio.Task = self.loop.create_task(
typing_cast(
Coroutine[None, None, None],
self.app(build_scope(environ), self.asgi_receive, self.asgi_send),
)
)
run_asgi.add_done_callback(self.asgi_done_callback)
return run_asgi
def execute_in_loop(self, coro: Coroutine[None, None, T]) -> T:
return asyncio.run_coroutine_threadsafe(coro, self.loop).result()
def __call__(
self, environ: Environ, start_response: StartResponse
) -> IterableChunks:
read_count: int = 0
body = environ["wsgi.input"] or BytesIO()
content_length = int(environ.get("CONTENT_LENGTH", None) or 0)
receive_eof = False
body_sent = False
asgi_task = self.execute_in_loop(self.start_asgi_app(environ))
# activate loop
self.loop.call_soon_threadsafe(lambda: None)
while True:
message = self.sync_event.wait()
self.loop.call_soon_threadsafe(self.sync_event_set_lock.release)
message_type = message["type"]
if message_type == "http.response.start":
start_response(
StatusStringMapping[message["status"]],
[
(
name.strip().decode("latin1"),
value.strip().decode("latin1"),
)
for name, value in message["headers"]
],
None,
)
self.send_event.set(None)
elif message_type == "http.response.body":
yield message.get("body", b"")
body_sent = True
self.wsgi_should_stop = not message.get("more_body", False)
self.send_event.set(None)
elif message_type == "http.response.disconnect":
self.wsgi_should_stop = True
self.send_event.set(None)
# ASGI application error
elif message_type == "a2wsgi.error":
if body_sent:
raise message["exception"][1].with_traceback(
message["exception"][2]
)
start_response(
"500 Internal Server Error",
[
("Content-Type", "text/plain; charset=utf-8"),
("Content-Length", "28"),
],
message["exception"],
)
yield b"Server got itself in trouble"
self.wsgi_should_stop = True
elif message_type == "receive":
read_size = min(65536, content_length - read_count)
if read_size == 0: # No more body, so don't read anymore
if not receive_eof:
self.receive_event.set(
{"type": "http.request", "body": b"", "more_body": False}
)
receive_eof = True
else:
pass # let `await receive()` wait
else:
data: bytes = body.read(read_size)
read_count += len(data)
more_body = read_count < content_length
self.receive_event.set(
{"type": "http.request", "body": data, "more_body": more_body}
)
if more_body is False:
receive_eof = True
else:
raise RuntimeError(f"Unknown message type: {message_type}")
if self.wsgi_should_stop:
self.receive_event.set({"type": "http.disconnect"})
break
if self.asgi_done.is_set():
break
# HTTP response ends, wait for run_asgi's background tasks
self.asgi_done.wait(self.wait_time)
self.loop.call_soon_threadsafe(asgi_task.cancel)
yield b""

View File

@@ -0,0 +1,182 @@
"""
https://asgi.readthedocs.io/en/latest/specs/index.html
"""
import sys
from typing import (
Any,
Awaitable,
Callable,
Dict,
Iterable,
Literal,
Optional,
Tuple,
TypedDict,
Union,
)
if sys.version_info >= (3, 11):
from typing import NotRequired
else:
from typing_extensions import NotRequired
class ASGIVersions(TypedDict):
spec_version: str
version: Literal["3.0"]
class HTTPScope(TypedDict):
type: Literal["http"]
asgi: ASGIVersions
http_version: str
method: str
scheme: str
path: str
raw_path: NotRequired[bytes]
query_string: bytes
root_path: str
headers: Iterable[Tuple[bytes, bytes]]
client: NotRequired[Tuple[str, int]]
server: NotRequired[Tuple[str, Optional[int]]]
state: NotRequired[Dict[str, Any]]
extensions: NotRequired[Dict[str, Dict[object, object]]]
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: NotRequired[Tuple[str, int]]
server: NotRequired[Tuple[str, Optional[int]]]
subprotocols: Iterable[str]
state: NotRequired[Dict[str, Any]]
extensions: NotRequired[Dict[str, Dict[object, object]]]
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: NotRequired[bool]
class HTTPResponseStartEvent(TypedDict):
type: Literal["http.response.start"]
status: int
headers: NotRequired[Iterable[Tuple[bytes, bytes]]]
trailers: NotRequired[bool]
class HTTPResponseBodyEvent(TypedDict):
type: Literal["http.response.body"]
body: NotRequired[bytes]
more_body: NotRequired[bool]
class HTTPDisconnectEvent(TypedDict):
type: Literal["http.disconnect"]
class WebSocketConnectEvent(TypedDict):
type: Literal["websocket.connect"]
class WebSocketAcceptEvent(TypedDict):
type: Literal["websocket.accept"]
subprotocol: NotRequired[str]
headers: NotRequired[Iterable[Tuple[bytes, bytes]]]
class WebSocketReceiveEvent(TypedDict):
type: Literal["websocket.receive"]
bytes: NotRequired[bytes]
text: NotRequired[str]
class WebSocketSendEvent(TypedDict):
type: Literal["websocket.send"]
bytes: NotRequired[bytes]
text: NotRequired[str]
class WebSocketDisconnectEvent(TypedDict):
type: Literal["websocket.disconnect"]
code: int
class WebSocketCloseEvent(TypedDict):
type: Literal["websocket.close"]
code: NotRequired[int]
reason: NotRequired[str]
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
ReceiveEvent = Union[
HTTPRequestEvent,
HTTPDisconnectEvent,
WebSocketConnectEvent,
WebSocketReceiveEvent,
WebSocketDisconnectEvent,
LifespanStartupEvent,
LifespanShutdownEvent,
]
SendEvent = Union[
HTTPResponseStartEvent,
HTTPResponseBodyEvent,
HTTPDisconnectEvent,
WebSocketAcceptEvent,
WebSocketSendEvent,
WebSocketCloseEvent,
LifespanStartupCompleteEvent,
LifespanStartupFailedEvent,
LifespanShutdownCompleteEvent,
LifespanShutdownFailedEvent,
]
Receive = Callable[[], Awaitable[ReceiveEvent]]
Send = Callable[[SendEvent], Awaitable[None]]
ASGIApp = Callable[[Scope, Receive, Send], Awaitable[None]]

View File

@@ -0,0 +1,265 @@
import asyncio
import contextvars
import functools
import os
import sys
import typing
from concurrent.futures import ThreadPoolExecutor
from .asgi_typing import HTTPScope, Scope, Receive, Send, SendEvent
from .wsgi_typing import Environ, StartResponse, ExceptionInfo, WSGIApp, WriteCallable
class Body:
def __init__(self, loop: asyncio.AbstractEventLoop, receive: Receive) -> None:
self.buffer = bytearray()
self.loop = loop
self.receive = receive
self._has_more = True
@property
def has_more(self) -> bool:
if self._has_more or self.buffer:
return True
return False
def _receive_more_data(self) -> bytes:
if not self._has_more:
return b""
future = asyncio.run_coroutine_threadsafe(self.receive(), loop=self.loop)
message = future.result()
self._has_more = message.get("more_body", False)
return message.get("body", b"")
def read(self, size: int = -1) -> bytes:
while size == -1 or size > len(self.buffer):
self.buffer.extend(self._receive_more_data())
if not self._has_more:
break
if size == -1:
result = bytes(self.buffer)
self.buffer.clear()
else:
result = bytes(self.buffer[:size])
del self.buffer[:size]
return result
def readline(self, limit: int = -1) -> bytes:
while True:
lf_index = self.buffer.find(b"\n", 0, limit if limit > -1 else None)
if lf_index != -1:
result = bytes(self.buffer[: lf_index + 1])
del self.buffer[: lf_index + 1]
return result
elif limit != -1:
result = bytes(self.buffer[:limit])
del self.buffer[:limit]
return result
if not self._has_more:
break
self.buffer.extend(self._receive_more_data())
result = bytes(self.buffer)
self.buffer.clear()
return result
def readlines(self, hint: int = -1) -> typing.List[bytes]:
if not self.has_more:
return []
if hint == -1:
raw_data = self.read(-1)
bytelist = raw_data.split(b"\n")
if raw_data[-1] == 10: # 10 -> b"\n"
bytelist.pop(len(bytelist) - 1)
return [line + b"\n" for line in bytelist]
return [self.readline() for _ in range(hint)]
def __iter__(self) -> typing.Generator[bytes, None, None]:
while self.has_more:
yield self.readline()
ENC, ESC = sys.getfilesystemencoding(), "surrogateescape"
def unicode_to_wsgi(u):
"""Convert an environment variable to a WSGI "bytes-as-unicode" string"""
return u.encode(ENC, ESC).decode("iso-8859-1")
def build_environ(scope: HTTPScope, body: Body) -> Environ:
"""
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) :]
script_name_environ_var = os.environ.get("SCRIPT_NAME", "")
if script_name_environ_var:
script_name = unicode_to_wsgi(script_name_environ_var)
environ: Environ = {
"asgi.scope": scope, # type: ignore a2wsgi
"REQUEST_METHOD": scope["method"],
"SCRIPT_NAME": script_name,
"PATH_INFO": path_info,
"QUERY_STRING": scope["query_string"].decode("ascii"),
"SERVER_PROTOCOL": f"HTTP/{scope['http_version']}",
"wsgi.version": (1, 0),
"wsgi.url_scheme": scope.get("scheme", "http"),
"wsgi.input": body,
"wsgi.errors": sys.stdout,
"wsgi.multithread": True,
"wsgi.multiprocess": True,
"wsgi.run_once": False,
}
# Get server name and port - required in WSGI, not in ASGI
server_addr, server_port = scope.get("server") or ("localhost", 80)
environ["SERVER_NAME"] = server_addr
environ["SERVER_PORT"] = str(server_port or 0)
# Get client IP address
client = scope.get("client")
if client is not None:
addr, port = client
environ["REMOTE_ADDR"] = addr
environ["REMOTE_PORT"] = str(port)
# Go through headers and make them into environ entries
for name, value in 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 = f"HTTP_{name}".upper().replace("-", "_")
# HTTPbis say only ASCII chars are allowed in headers, but we latin1 just in case
value = value.decode("latin1")
if corrected_name in environ:
value = environ[corrected_name] + "," + value
environ[corrected_name] = value
return environ
class WSGIMiddleware:
"""
Convert WSGIApp to ASGIApp.
"""
def __init__(
self, app: WSGIApp, workers: int = 10, send_queue_size: int = 10
) -> None:
self.app = app
self.send_queue_size = send_queue_size
self.executor = ThreadPoolExecutor(
thread_name_prefix="WSGI", max_workers=workers
)
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] == "http":
responder = WSGIResponder(self.app, self.executor, self.send_queue_size)
return await responder(scope, receive, send)
if scope["type"] == "websocket":
await send({"type": "websocket.close", "code": 1000})
return
if scope["type"] == "lifespan":
message = await receive()
assert message["type"] == "lifespan.startup"
await send({"type": "lifespan.startup.complete"})
message = await receive()
assert message["type"] == "lifespan.shutdown"
await send({"type": "lifespan.shutdown.complete"})
return
class WSGIResponder:
def __init__(
self, app: WSGIApp, executor: ThreadPoolExecutor, send_queue_size: int
) -> None:
self.app = app
self.executor = executor
self.loop = asyncio.get_event_loop()
self.send_queue = asyncio.Queue(send_queue_size)
self.response_started = False
self.exc_info: typing.Any = None
async def __call__(self, scope: HTTPScope, receive: Receive, send: Send) -> None:
body = Body(self.loop, receive)
environ = build_environ(scope, body)
sender = None
try:
sender = self.loop.create_task(self.sender(send))
context = contextvars.copy_context()
func = functools.partial(context.run, self.wsgi)
await self.loop.run_in_executor(
self.executor, func, environ, self.start_response
)
await self.send_queue.put(None)
# Sender may raise an exception, so we need to await it
# dont await send_queue.join() because it will never finish
await sender
if self.exc_info is not None:
raise self.exc_info[0].with_traceback(
self.exc_info[1], self.exc_info[2]
)
finally:
if sender and not sender.done():
sender.cancel() # pragma: no cover
def send(self, message: typing.Optional[SendEvent]) -> None:
future = asyncio.run_coroutine_threadsafe(
self.send_queue.put(message), loop=self.loop
)
future.result()
async def sender(self, send: Send) -> None:
while True:
message = await self.send_queue.get()
if message is None:
break
await send(message)
self.send_queue.task_done()
def start_response(
self,
status: str,
response_headers: typing.List[typing.Tuple[str, str]],
exc_info: typing.Optional[ExceptionInfo] = None,
) -> WriteCallable:
self.exc_info = exc_info
if not self.response_started:
self.response_started = True
status_code_string, _ = status.split(" ", 1)
status_code = int(status_code_string)
headers = [
(name.strip().encode("latin1").lower(), value.strip().encode("latin1"))
for name, value in response_headers
]
self.send(
{
"type": "http.response.start",
"status": status_code,
"headers": headers,
}
)
return lambda chunk: self.send(
{"type": "http.response.body", "body": chunk, "more_body": True}
)
def wsgi(self, environ: Environ, start_response: StartResponse) -> None:
iterable = self.app(environ, start_response)
try:
for chunk in iterable:
self.send(
{"type": "http.response.body", "body": chunk, "more_body": True}
)
self.send({"type": "http.response.body", "body": b""})
finally:
getattr(iterable, "close", lambda: None)()

View File

@@ -0,0 +1,194 @@
"""
https://peps.python.org/pep-3333/
"""
from types import TracebackType
from typing import (
Any,
Callable,
Iterable,
List,
Optional,
Protocol,
Tuple,
Type,
TypedDict,
)
CGIRequiredDefined = TypedDict(
"CGIRequiredDefined",
{
# The HTTP request method, such as GET or POST. This cannot ever be an
# empty string, and so is always required.
"REQUEST_METHOD": str,
# When HTTP_HOST is not set, these variables can be combined to determine
# a default.
# SERVER_NAME and SERVER_PORT are required strings and must never be empty.
"SERVER_NAME": str,
"SERVER_PORT": str,
# The version of the protocol the client used to send the request.
# Typically this will be something like "HTTP/1.0" or "HTTP/1.1" and
# may be used by the application to determine how to treat any HTTP
# request headers. (This variable should probably be called REQUEST_PROTOCOL,
# since it denotes the protocol used in the request, and is not necessarily
# the protocol that will be used in the server's response. However, for
# compatibility with CGI we have to keep the existing name.)
"SERVER_PROTOCOL": str,
},
)
CGIOptionalDefined = TypedDict(
"CGIOptionalDefined",
{
"REQUEST_URI": str,
"REMOTE_ADDR": str,
"REMOTE_PORT": str,
# The initial portion of the request URLs “path” that corresponds to the
# application object, so that the application knows its virtual “location”.
# This may be an empty string, if the application corresponds to the “root”
# of the server.
"SCRIPT_NAME": str,
# The remainder of the request URLs “path”, designating the virtual
# “location” of the requests target within the application. This may be an
# empty string, if the request URL targets the application root and does
# not have a trailing slash.
"PATH_INFO": str,
# The portion of the request URL that follows the “?”, if any. May be empty
# or absent.
"QUERY_STRING": str,
# The contents of any Content-Type fields in the HTTP request. May be empty
# or absent.
"CONTENT_TYPE": str,
# The contents of any Content-Length fields in the HTTP request. May be empty
# or absent.
"CONTENT_LENGTH": str,
},
total=False,
)
class InputStream(Protocol):
"""
An input stream (file-like object) from which the HTTP request body bytes can be
read. (The server or gateway may perform reads on-demand as requested by the
application, or it may pre- read the client's request body and buffer it in-memory
or on disk, or use any other technique for providing such an input stream, according
to its preference.)
"""
def read(self, size: int = -1, /) -> bytes:
"""
The server is not required to read past the client's specified Content-Length,
and should simulate an end-of-file condition if the application attempts to read
past that point. The application should not attempt to read more data than is
specified by the CONTENT_LENGTH variable.
A server should allow read() to be called without an argument, and return the
remainder of the client's input stream.
A server should return empty bytestrings from any attempt to read from an empty
or exhausted input stream.
"""
raise NotImplementedError
def readline(self, limit: int = -1, /) -> bytes:
"""
Servers should support the optional "size" argument to readline(), but as in
WSGI 1.0, they are allowed to omit support for it.
(In WSGI 1.0, the size argument was not supported, on the grounds that it might
have been complex to implement, and was not often used in practice... but then
the cgi module started using it, and so practical servers had to start
supporting it anyway!)
"""
raise NotImplementedError
def readlines(self, hint: int = -1, /) -> List[bytes]:
"""
Note that the hint argument to readlines() is optional for both caller and
implementer. The application is free not to supply it, and the server or gateway
is free to ignore it.
"""
raise NotImplementedError
class ErrorStream(Protocol):
"""
An output stream (file-like object) to which error output can be written,
for the purpose of recording program or other errors in a standardized and
possibly centralized location. This should be a "text mode" stream;
i.e., applications should use "\n" as a line ending, and assume that it will
be converted to the correct line ending by the server/gateway.
(On platforms where the str type is unicode, the error stream should accept
and log arbitrary unicode without raising an error; it is allowed, however,
to substitute characters that cannot be rendered in the stream's encoding.)
For many servers, wsgi.errors will be the server's main error log. Alternatively,
this may be sys.stderr, or a log file of some sort. The server's documentation
should include an explanation of how to configure this or where to find the
recorded output. A server or gateway may supply different error streams to
different applications, if this is desired.
"""
def flush(self) -> None:
"""
Since the errors stream may not be rewound, servers and gateways are free to
forward write operations immediately, without buffering. In this case, the
flush() method may be a no-op. Portable applications, however, cannot assume
that output is unbuffered or that flush() is a no-op. They must call flush()
if they need to ensure that output has in fact been written.
(For example, to minimize intermingling of data from multiple processes writing
to the same error log.)
"""
raise NotImplementedError
def write(self, s: str, /) -> Any:
raise NotImplementedError
def writelines(self, seq: List[str], /) -> Any:
raise NotImplementedError
WSGIDefined = TypedDict(
"WSGIDefined",
{
"wsgi.version": Tuple[int, int], # e.g. (1, 0)
"wsgi.url_scheme": str, # e.g. "http" or "https"
"wsgi.input": InputStream,
"wsgi.errors": ErrorStream,
# This value should evaluate true if the application object may be simultaneously
# invoked by another thread in the same process, and should evaluate false otherwise.
"wsgi.multithread": bool,
# This value should evaluate true if an equivalent application object may be
# simultaneously invoked by another process, and should evaluate false otherwise.
"wsgi.multiprocess": bool,
# This value should evaluate true if the server or gateway expects (but does
# not guarantee!) that the application will only be invoked this one time during
# the life of its containing process. Normally, this will only be true for a
# gateway based on CGI (or something similar).
"wsgi.run_once": bool,
},
)
class Environ(CGIRequiredDefined, CGIOptionalDefined, WSGIDefined):
"""
WSGI Environ
"""
ExceptionInfo = Tuple[Type[BaseException], BaseException, Optional[TracebackType]]
# https://peps.python.org/pep-3333/#the-write-callable
WriteCallable = Callable[[bytes], None]
class StartResponse(Protocol):
def __call__(
self,
status: str,
response_headers: List[Tuple[str, str]],
exc_info: Optional[ExceptionInfo] = None,
/,
) -> WriteCallable:
raise NotImplementedError
IterableChunks = Iterable[bytes]
WSGIApp = Callable[[Environ, StartResponse], IterableChunks]

View File

@@ -0,0 +1,123 @@
Metadata-Version: 2.4
Name: aiosqlite
Version: 0.22.1
Summary: asyncio bridge to the standard sqlite3 module
Author-email: Amethyst Reese <amethyst@n7.gg>
Requires-Python: >=3.9
Description-Content-Type: text/x-rst
Classifier: Development Status :: 5 - Production/Stable
Classifier: Framework :: AsyncIO
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Topic :: Software Development :: Libraries
License-File: LICENSE
Requires-Dist: attribution==1.8.0 ; extra == "dev"
Requires-Dist: black==25.11.0 ; extra == "dev"
Requires-Dist: build>=1.2 ; extra == "dev"
Requires-Dist: coverage[toml]==7.10.7 ; extra == "dev"
Requires-Dist: flake8==7.3.0 ; extra == "dev"
Requires-Dist: flake8-bugbear==24.12.12 ; extra == "dev"
Requires-Dist: flit==3.12.0 ; extra == "dev"
Requires-Dist: mypy==1.19.0 ; extra == "dev"
Requires-Dist: ufmt==2.8.0 ; extra == "dev"
Requires-Dist: usort==1.0.8.post1 ; extra == "dev"
Requires-Dist: sphinx==8.1.3 ; extra == "docs"
Requires-Dist: sphinx-mdinclude==0.6.2 ; extra == "docs"
Project-URL: Documentation, https://aiosqlite.omnilib.dev
Project-URL: Github, https://github.com/omnilib/aiosqlite
Provides-Extra: dev
Provides-Extra: docs
aiosqlite\: Sqlite for AsyncIO
==============================
.. image:: https://readthedocs.org/projects/aiosqlite/badge/?version=latest
:target: https://aiosqlite.omnilib.dev/en/latest/?badge=latest
:alt: Documentation Status
.. image:: https://img.shields.io/pypi/v/aiosqlite.svg
:target: https://pypi.org/project/aiosqlite
:alt: PyPI Release
.. image:: https://img.shields.io/badge/change-log-blue
:target: https://github.com/omnilib/aiosqlite/blob/master/CHANGELOG.md
:alt: Changelog
.. image:: https://img.shields.io/pypi/l/aiosqlite.svg
:target: https://github.com/omnilib/aiosqlite/blob/master/LICENSE
:alt: MIT Licensed
aiosqlite provides a friendly, async interface to sqlite databases.
It replicates the standard ``sqlite3`` module, but with async versions
of all the standard connection and cursor methods, plus context managers for
automatically closing connections and cursors:
.. code-block:: python
async with aiosqlite.connect(...) as db:
await db.execute("INSERT INTO some_table ...")
await db.commit()
async with db.execute("SELECT * FROM some_table") as cursor:
async for row in cursor:
...
It can also be used in the traditional, procedural manner:
.. code-block:: python
db = await aiosqlite.connect(...)
cursor = await db.execute('SELECT * FROM some_table')
row = await cursor.fetchone()
rows = await cursor.fetchall()
await cursor.close()
await db.close()
aiosqlite also replicates most of the advanced features of ``sqlite3``:
.. code-block:: python
async with aiosqlite.connect(...) as db:
db.row_factory = aiosqlite.Row
async with db.execute('SELECT * FROM some_table') as cursor:
async for row in cursor:
value = row['column']
await db.execute('INSERT INTO foo some_table')
assert db.total_changes > 0
Install
-------
aiosqlite is compatible with Python 3.8 and newer.
You can install it from PyPI:
.. code-block:: console
$ pip install aiosqlite
Details
-------
aiosqlite allows interaction with SQLite databases on the main AsyncIO event
loop without blocking execution of other coroutines while waiting for queries
or data fetches. It does this by using a single, shared thread per connection.
This thread executes all actions within a shared request queue to prevent
overlapping actions.
Connection objects are proxies to the real connections, contain the shared
execution thread, and provide context managers to handle automatically closing
connections. Cursors are similarly proxies to the real cursors, and provide
async iterators to query results.
License
-------
aiosqlite is copyright `Amethyst Reese <https://noswap.com>`_, and licensed under the
MIT license. I am providing code in this repository to you under an open source
license. This is my personal repository; the license you receive to my code
is from me and not from my employer. See the `LICENSE`_ file for details.
.. _LICENSE: https://github.com/omnilib/aiosqlite/blob/master/LICENSE

View File

@@ -0,0 +1,27 @@
aiosqlite-0.22.1.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
aiosqlite-0.22.1.dist-info/METADATA,sha256=zzyMxzl2h_dGAlV6Pk9c4YBlkaYsgv6UybOW_YDRs5o,4311
aiosqlite-0.22.1.dist-info/RECORD,,
aiosqlite-0.22.1.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
aiosqlite-0.22.1.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
aiosqlite-0.22.1.dist-info/licenses/LICENSE,sha256=qwwXHcPvi_MlqEu3fYVUIfJhEzXd9uCIFrKSLE7cD3Y,1071
aiosqlite/__init__.py,sha256=kjZKcYP2eZ3IbBEHQ0D_Owsk_-FlRGEjQWlbybOs8jk,888
aiosqlite/__pycache__/__init__.cpython-312.pyc,,
aiosqlite/__pycache__/__version__.cpython-312.pyc,,
aiosqlite/__pycache__/context.cpython-312.pyc,,
aiosqlite/__pycache__/core.cpython-312.pyc,,
aiosqlite/__pycache__/cursor.cpython-312.pyc,,
aiosqlite/__version__.py,sha256=sEM7xBU6e8WQYHe3ESoQmkEbXGKrqXjrQ5ujl5zpyV0,157
aiosqlite/context.py,sha256=9jJcPG_SGSshzNUwXy87C1__mrKGFbToX0UuOQ1uItQ,1448
aiosqlite/core.py,sha256=eXar7Bxr1pQz3VShCD0t6zGpp_bwwvFtVzVdzS2opII,15095
aiosqlite/cursor.py,sha256=X3k2gYJeo3yB84scDEAPFZpsC_rzjT8dT4p6W3MeezM,3476
aiosqlite/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
aiosqlite/tests/__init__.py,sha256=sp0-HYboM6gOYrUxWy8xna-hdJyMUtKBvAKrpRBcDCE,90
aiosqlite/tests/__main__.py,sha256=eZRuAxr1bwF9xAAqVjCi4vd1WFsFO35uyhtuVO0GjmY,162
aiosqlite/tests/__pycache__/__init__.cpython-312.pyc,,
aiosqlite/tests/__pycache__/__main__.cpython-312.pyc,,
aiosqlite/tests/__pycache__/helpers.cpython-312.pyc,,
aiosqlite/tests/__pycache__/perf.cpython-312.pyc,,
aiosqlite/tests/__pycache__/smoke.cpython-312.pyc,,
aiosqlite/tests/helpers.py,sha256=MWC839FiX63TBmFiIjabXNx-4G5eWYnE5MiInKIAdJw,722
aiosqlite/tests/perf.py,sha256=-ipnXSHidO6VBKEdLAOcGa3cKHU5ul1w8-ifDNtGbfA,7249
aiosqlite/tests/smoke.py,sha256=k5mp4AOHheOO6wKL_bgdH1fenY6-ve6aew19ifmcIWA,19851

View File

@@ -0,0 +1,4 @@
Wheel-Version: 1.0
Generator: flit 3.12.0
Root-Is-Purelib: true
Tag: py3-none-any

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 Amethyst Reese
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,44 @@
# Copyright Amethyst Reese
# Licensed under the MIT license
"""asyncio bridge to the standard sqlite3 module"""
from sqlite3 import ( # pylint: disable=redefined-builtin
DatabaseError,
Error,
IntegrityError,
NotSupportedError,
OperationalError,
paramstyle,
ProgrammingError,
register_adapter,
register_converter,
Row,
sqlite_version,
sqlite_version_info,
Warning,
)
__author__ = "Amethyst Reese"
from .__version__ import __version__
from .core import connect, Connection, Cursor
__all__ = [
"__version__",
"paramstyle",
"register_adapter",
"register_converter",
"sqlite_version",
"sqlite_version_info",
"connect",
"Connection",
"Cursor",
"Row",
"Warning",
"Error",
"DatabaseError",
"IntegrityError",
"ProgrammingError",
"OperationalError",
"NotSupportedError",
]

View File

@@ -0,0 +1,7 @@
"""
This file is automatically generated by attribution.
Do not edit manually. Get more info at https://attribution.omnilib.dev
"""
__version__ = "0.22.1"

View File

@@ -0,0 +1,56 @@
# Copyright Amethyst Reese
# Licensed under the MIT license
from collections.abc import Coroutine, Generator
from contextlib import AbstractAsyncContextManager
from functools import wraps
from typing import Any, Callable, TypeVar
from .cursor import Cursor
_T = TypeVar("_T")
class Result(AbstractAsyncContextManager[_T], Coroutine[Any, Any, _T]):
__slots__ = ("_coro", "_obj")
def __init__(self, coro: Coroutine[Any, Any, _T]):
self._coro = coro
self._obj: _T
def send(self, value) -> None:
return self._coro.send(value)
def throw(self, typ, val=None, tb=None) -> None:
if val is None:
return self._coro.throw(typ)
if tb is None:
return self._coro.throw(typ, val)
return self._coro.throw(typ, val, tb)
def close(self) -> None:
return self._coro.close()
def __await__(self) -> Generator[Any, None, _T]:
return self._coro.__await__()
async def __aenter__(self) -> _T:
self._obj = await self._coro
return self._obj
async def __aexit__(self, exc_type, exc, tb) -> None:
if isinstance(self._obj, Cursor):
await self._obj.close()
def contextmanager(
method: Callable[..., Coroutine[Any, Any, _T]],
) -> Callable[..., Result[_T]]:
@wraps(method)
def wrapper(self, *args, **kwargs) -> Result[_T]:
return Result(method(self, *args, **kwargs))
return wrapper

View File

@@ -0,0 +1,468 @@
# Copyright Amethyst Reese
# Licensed under the MIT license
"""
Core implementation of aiosqlite proxies
"""
import asyncio
import logging
import sqlite3
from collections.abc import AsyncIterator, Generator, Iterable
from functools import partial
from pathlib import Path
from queue import Empty, Queue, SimpleQueue
from threading import Thread
from typing import Any, Callable, Literal, Optional, Union
from warnings import warn
from .context import contextmanager
from .cursor import Cursor
__all__ = ["connect", "Connection", "Cursor"]
AuthorizerCallback = Callable[[int, str, str, str, str], int]
LOG = logging.getLogger("aiosqlite")
IsolationLevel = Optional[Literal["DEFERRED", "IMMEDIATE", "EXCLUSIVE"]]
def set_result(fut: asyncio.Future, result: Any) -> None:
"""Set the result of a future if it hasn't been set already."""
if not fut.done():
fut.set_result(result)
def set_exception(fut: asyncio.Future, e: BaseException) -> None:
"""Set the exception of a future if it hasn't been set already."""
if not fut.done():
fut.set_exception(e)
_STOP_RUNNING_SENTINEL = object()
_TxQueue = SimpleQueue[tuple[Optional[asyncio.Future], Callable[[], Any]]]
def _connection_worker_thread(tx: _TxQueue):
"""
Execute function calls on a separate thread.
:meta private:
"""
while True:
# Continues running until all queue items are processed,
# even after connection is closed (so we can finalize all
# futures)
future, function = tx.get()
try:
LOG.debug("executing %s", function)
result = function()
if future:
future.get_loop().call_soon_threadsafe(set_result, future, result)
LOG.debug("operation %s completed", function)
if result is _STOP_RUNNING_SENTINEL:
break
except BaseException as e: # noqa B036
LOG.debug("returning exception %s", e)
if future:
future.get_loop().call_soon_threadsafe(set_exception, future, e)
class Connection:
def __init__(
self,
connector: Callable[[], sqlite3.Connection],
iter_chunk_size: int,
loop: Optional[asyncio.AbstractEventLoop] = None,
) -> None:
self._running = True
self._connection: Optional[sqlite3.Connection] = None
self._connector = connector
self._tx: _TxQueue = SimpleQueue()
self._iter_chunk_size = iter_chunk_size
self._thread = Thread(target=_connection_worker_thread, args=(self._tx,))
if loop is not None:
warn(
"aiosqlite.Connection no longer uses the `loop` parameter",
DeprecationWarning,
)
def __del__(self):
if self._connection is None:
return
warn(
(
f"{self!r} was deleted before being closed. "
"Please use 'async with' or '.close()' to close the connection properly."
),
ResourceWarning,
stacklevel=1,
)
# Don't try to be creative here, the event loop may have already been closed.
# Simply stop the worker thread, and let the underlying sqlite3 connection
# be finalized by its own __del__.
self.stop()
def stop(self) -> Optional[asyncio.Future]:
"""Stop the background thread. Prefer `async with` or `await close()`"""
self._running = False
def close_and_stop():
if self._connection is not None:
self._connection.close()
self._connection = None
return _STOP_RUNNING_SENTINEL
try:
future = asyncio.get_event_loop().create_future()
except Exception:
future = None
self._tx.put_nowait((future, close_and_stop))
return future
@property
def _conn(self) -> sqlite3.Connection:
if self._connection is None:
raise ValueError("no active connection")
return self._connection
def _execute_insert(self, sql: str, parameters: Any) -> Optional[sqlite3.Row]:
cursor = self._conn.execute(sql, parameters)
cursor.execute("SELECT last_insert_rowid()")
return cursor.fetchone()
def _execute_fetchall(self, sql: str, parameters: Any) -> Iterable[sqlite3.Row]:
cursor = self._conn.execute(sql, parameters)
return cursor.fetchall()
async def _execute(self, fn, *args, **kwargs):
"""Queue a function with the given arguments for execution."""
if not self._running or not self._connection:
raise ValueError("Connection closed")
function = partial(fn, *args, **kwargs)
future = asyncio.get_event_loop().create_future()
self._tx.put_nowait((future, function))
return await future
async def _connect(self) -> "Connection":
"""Connect to the actual sqlite database."""
if self._connection is None:
try:
future = asyncio.get_event_loop().create_future()
self._tx.put_nowait((future, self._connector))
self._connection = await future
except BaseException:
self.stop()
self._connection = None
raise
return self
def __await__(self) -> Generator[Any, None, "Connection"]:
self._thread.start()
return self._connect().__await__()
async def __aenter__(self) -> "Connection":
return await self
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
await self.close()
@contextmanager
async def cursor(self) -> Cursor:
"""Create an aiosqlite cursor wrapping a sqlite3 cursor object."""
return Cursor(self, await self._execute(self._conn.cursor))
async def commit(self) -> None:
"""Commit the current transaction."""
await self._execute(self._conn.commit)
async def rollback(self) -> None:
"""Roll back the current transaction."""
await self._execute(self._conn.rollback)
async def close(self) -> None:
"""Complete queued queries/cursors and close the connection."""
if self._connection is None:
return
try:
await self._execute(self._conn.close)
except Exception:
LOG.info("exception occurred while closing connection")
raise
finally:
self._connection = None
future = self.stop()
if future:
await future
@contextmanager
async def execute(
self, sql: str, parameters: Optional[Iterable[Any]] = None
) -> Cursor:
"""Helper to create a cursor and execute the given query."""
if parameters is None:
parameters = []
cursor = await self._execute(self._conn.execute, sql, parameters)
return Cursor(self, cursor)
@contextmanager
async def execute_insert(
self, sql: str, parameters: Optional[Iterable[Any]] = None
) -> Optional[sqlite3.Row]:
"""Helper to insert and get the last_insert_rowid."""
if parameters is None:
parameters = []
return await self._execute(self._execute_insert, sql, parameters)
@contextmanager
async def execute_fetchall(
self, sql: str, parameters: Optional[Iterable[Any]] = None
) -> Iterable[sqlite3.Row]:
"""Helper to execute a query and return all the data."""
if parameters is None:
parameters = []
return await self._execute(self._execute_fetchall, sql, parameters)
@contextmanager
async def executemany(
self, sql: str, parameters: Iterable[Iterable[Any]]
) -> Cursor:
"""Helper to create a cursor and execute the given multiquery."""
cursor = await self._execute(self._conn.executemany, sql, parameters)
return Cursor(self, cursor)
@contextmanager
async def executescript(self, sql_script: str) -> Cursor:
"""Helper to create a cursor and execute a user script."""
cursor = await self._execute(self._conn.executescript, sql_script)
return Cursor(self, cursor)
async def interrupt(self) -> None:
"""Interrupt pending queries."""
return self._conn.interrupt()
async def create_function(
self, name: str, num_params: int, func: Callable, deterministic: bool = False
) -> None:
"""
Create user-defined function that can be later used
within SQL statements. Must be run within the same thread
that query executions take place so instead of executing directly
against the connection, we defer this to `run` function.
If ``deterministic`` is true, the created function is marked as deterministic,
which allows SQLite to perform additional optimizations. This flag is supported
by SQLite 3.8.3 or higher, ``NotSupportedError`` will be raised if used with
older versions.
"""
await self._execute(
self._conn.create_function,
name,
num_params,
func,
deterministic=deterministic,
)
@property
def in_transaction(self) -> bool:
return self._conn.in_transaction
@property
def isolation_level(self) -> Optional[str]:
return self._conn.isolation_level
@isolation_level.setter
def isolation_level(self, value: IsolationLevel) -> None:
self._conn.isolation_level = value
@property
def row_factory(self) -> Optional[type]:
return self._conn.row_factory
@row_factory.setter
def row_factory(self, factory: Optional[type]) -> None:
self._conn.row_factory = factory
@property
def text_factory(self) -> Callable[[bytes], Any]:
return self._conn.text_factory
@text_factory.setter
def text_factory(self, factory: Callable[[bytes], Any]) -> None:
self._conn.text_factory = factory
@property
def total_changes(self) -> int:
return self._conn.total_changes
async def enable_load_extension(self, value: bool) -> None:
await self._execute(self._conn.enable_load_extension, value) # type: ignore
async def load_extension(self, path: str):
await self._execute(self._conn.load_extension, path) # type: ignore
async def set_progress_handler(
self, handler: Callable[[], Optional[int]], n: int
) -> None:
await self._execute(self._conn.set_progress_handler, handler, n)
async def set_trace_callback(self, handler: Callable) -> None:
await self._execute(self._conn.set_trace_callback, handler)
async def set_authorizer(
self, authorizer_callback: Optional[AuthorizerCallback]
) -> None:
"""
Set an authorizer callback to control database access.
The authorizer callback is invoked for each SQL statement that is prepared,
and controls whether specific operations are permitted.
Example::
import sqlite3
def restrict_drops(action_code, arg1, arg2, db_name, trigger_name):
# Deny all DROP operations
if action_code == sqlite3.SQLITE_DROP_TABLE:
return sqlite3.SQLITE_DENY
# Allow everything else
return sqlite3.SQLITE_OK
await conn.set_authorizer(restrict_drops)
See ``sqlite3`` documentation for details:
https://docs.python.org/3/library/sqlite3.html#sqlite3.Connection.set_authorizer
:param authorizer_callback: An optional callable that receives five arguments:
- ``action_code`` (int): The action to be authorized (e.g., ``SQLITE_READ``)
- ``arg1`` (str): First argument, meaning depends on ``action_code``
- ``arg2`` (str): Second argument, meaning depends on ``action_code``
- ``db_name`` (str): Database name (e.g., ``"main"``, ``"temp"``)
- ``trigger_name`` (str): Name of trigger or view that is doing the access,
or ``None``
The callback should return:
- ``SQLITE_OK`` (0): Allow the operation
- ``SQLITE_DENY`` (1): Deny the operation, raise ``sqlite3.DatabaseError``
- ``SQLITE_IGNORE`` (2): Treat operation as no-op
Pass ``None`` to remove the authorizer.
"""
await self._execute(self._conn.set_authorizer, authorizer_callback)
async def iterdump(self) -> AsyncIterator[str]:
"""
Return an async iterator to dump the database in SQL text format.
Example::
async for line in db.iterdump():
...
"""
dump_queue: Queue = Queue()
def dumper():
try:
for line in self._conn.iterdump():
dump_queue.put_nowait(line)
dump_queue.put_nowait(None)
except Exception:
LOG.exception("exception while dumping db")
dump_queue.put_nowait(None)
raise
fut = self._execute(dumper)
task = asyncio.ensure_future(fut)
while True:
try:
line: Optional[str] = dump_queue.get_nowait()
if line is None:
break
yield line
except Empty:
if task.done():
LOG.warning("iterdump completed unexpectedly")
break
await asyncio.sleep(0.01)
await task
async def backup(
self,
target: Union["Connection", sqlite3.Connection],
*,
pages: int = 0,
progress: Optional[Callable[[int, int, int], None]] = None,
name: str = "main",
sleep: float = 0.250,
) -> None:
"""
Make a backup of the current database to the target database.
Takes either a standard sqlite3 or aiosqlite Connection object as the target.
"""
if isinstance(target, Connection):
target = target._conn
await self._execute(
self._conn.backup,
target,
pages=pages,
progress=progress,
name=name,
sleep=sleep,
)
def connect(
database: Union[str, Path],
*,
iter_chunk_size=64,
loop: Optional[asyncio.AbstractEventLoop] = None,
**kwargs: Any,
) -> Connection:
"""Create and return a connection proxy to the sqlite database."""
if loop is not None:
warn(
"aiosqlite.connect() no longer uses the `loop` parameter",
DeprecationWarning,
)
def connector() -> sqlite3.Connection:
if isinstance(database, str):
loc = database
elif isinstance(database, bytes):
loc = database.decode("utf-8")
else:
loc = str(database)
return sqlite3.connect(loc, **kwargs)
return Connection(connector, iter_chunk_size)

View File

@@ -0,0 +1,110 @@
# Copyright Amethyst Reese
# Licensed under the MIT license
import sqlite3
from collections.abc import AsyncIterator, Iterable
from typing import Any, Callable, Optional, TYPE_CHECKING
if TYPE_CHECKING:
from .core import Connection
class Cursor:
def __init__(self, conn: "Connection", cursor: sqlite3.Cursor) -> None:
self.iter_chunk_size = conn._iter_chunk_size
self._conn = conn
self._cursor = cursor
def __aiter__(self) -> AsyncIterator[sqlite3.Row]:
"""The cursor proxy is also an async iterator."""
return self._fetch_chunked()
async def _fetch_chunked(self):
while True:
rows = await self.fetchmany(self.iter_chunk_size)
if not rows:
return
for row in rows:
yield row
async def _execute(self, fn, *args, **kwargs):
"""Execute the given function on the shared connection's thread."""
return await self._conn._execute(fn, *args, **kwargs)
async def execute(
self, sql: str, parameters: Optional[Iterable[Any]] = None
) -> "Cursor":
"""Execute the given query."""
if parameters is None:
parameters = []
await self._execute(self._cursor.execute, sql, parameters)
return self
async def executemany(
self, sql: str, parameters: Iterable[Iterable[Any]]
) -> "Cursor":
"""Execute the given multiquery."""
await self._execute(self._cursor.executemany, sql, parameters)
return self
async def executescript(self, sql_script: str) -> "Cursor":
"""Execute a user script."""
await self._execute(self._cursor.executescript, sql_script)
return self
async def fetchone(self) -> Optional[sqlite3.Row]:
"""Fetch a single row."""
return await self._execute(self._cursor.fetchone)
async def fetchmany(self, size: Optional[int] = None) -> Iterable[sqlite3.Row]:
"""Fetch up to `cursor.arraysize` number of rows."""
args: tuple[int, ...] = ()
if size is not None:
args = (size,)
return await self._execute(self._cursor.fetchmany, *args)
async def fetchall(self) -> Iterable[sqlite3.Row]:
"""Fetch all remaining rows."""
return await self._execute(self._cursor.fetchall)
async def close(self) -> None:
"""Close the cursor."""
await self._execute(self._cursor.close)
@property
def rowcount(self) -> int:
return self._cursor.rowcount
@property
def lastrowid(self) -> Optional[int]:
return self._cursor.lastrowid
@property
def arraysize(self) -> int:
return self._cursor.arraysize
@arraysize.setter
def arraysize(self, value: int) -> None:
self._cursor.arraysize = value
@property
def description(self) -> tuple[tuple[str, None, None, None, None, None, None], ...]:
return self._cursor.description
@property
def row_factory(self) -> Optional[Callable[[sqlite3.Cursor, sqlite3.Row], object]]:
return self._cursor.row_factory
@row_factory.setter
def row_factory(self, factory: Optional[type]) -> None:
self._cursor.row_factory = factory
@property
def connection(self) -> sqlite3.Connection:
return self._cursor.connection
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
await self.close()

View File

@@ -0,0 +1,4 @@
# Copyright Amethyst Reese
# Licensed under the MIT license
from .smoke import SmokeTest

View File

@@ -0,0 +1,7 @@
# Copyright Amethyst Reese
# Licensed under the MIT license
import unittest
if __name__ == "__main__":
unittest.main(module="aiosqlite.tests", verbosity=2)

View File

@@ -0,0 +1,29 @@
# Copyright Amethyst Reese
# Licensed under the MIT license
import logging
import sys
def setup_logger():
log = logging.getLogger("")
log.setLevel(logging.INFO)
logging.addLevelName(logging.ERROR, "E")
logging.addLevelName(logging.WARNING, "W")
logging.addLevelName(logging.INFO, "I")
logging.addLevelName(logging.DEBUG, "V")
date_fmt = r"%H:%M:%S"
verbose_fmt = (
"%(asctime)s,%(msecs)d %(levelname)s "
"%(module)s:%(funcName)s():%(lineno)d "
"%(message)s"
)
handler = logging.StreamHandler(sys.stdout)
handler.setLevel(logging.INFO)
handler.setFormatter(logging.Formatter(verbose_fmt, date_fmt))
log.addHandler(handler)
return log

View File

@@ -0,0 +1,221 @@
# Copyright Amethyst Reese
# Licensed under the MIT license
"""
Simple perf tests for aiosqlite and the asyncio run loop.
"""
import sqlite3
import string
import tempfile
import time
from unittest import IsolatedAsyncioTestCase as TestCase
import aiosqlite
from .smoke import setup_logger
TEST_DB = ":memory:"
TARGET = 2.0
RESULTS = {}
def timed(fn, name=None):
"""
Decorator for perf testing a block of async code.
Expects the wrapped function to return an async generator.
The generator should do setup, then yield when ready to start perf testing.
The decorator will then pump the generator repeatedly until the target
time has been reached, then close the generator and print perf results.
"""
name = name or fn.__name__
async def wrapper(*args, **kwargs):
gen = fn(*args, **kwargs)
await gen.asend(None)
count = 0
before = time.time()
while True:
count += 1
value = time.time() - before < TARGET
try:
if value:
await gen.asend(value)
else:
await gen.aclose()
break
except StopAsyncIteration:
break
except Exception as e:
print(f"exception occurred: {e}")
return
duration = time.time() - before
RESULTS[name] = (count, duration)
return wrapper
class PerfTest(TestCase):
@classmethod
def setUpClass(cls):
print(f"Running perf tests for at least {TARGET:.1f}s each...")
setup_logger()
@classmethod
def tearDownClass(cls):
print(f"\n{'Perf Test':<25} Iterations Duration {'Rate':>11}")
for name in sorted(RESULTS):
count, duration = RESULTS[name]
rate = count / duration
name = name.replace("test_", "")
print(f"{name:<25} {count:>10} {duration:>7.1f}s {rate:>9.1f}/s")
@timed
async def test_connection_memory(self):
while True:
yield
async with aiosqlite.connect(TEST_DB):
pass
@timed
async def test_connection_file(self):
with tempfile.NamedTemporaryFile(delete=False) as tf:
path = tf.name
tf.close()
async with aiosqlite.connect(path) as db:
await db.execute(
"create table perf (i integer primary key asc, k integer)"
)
await db.execute("insert into perf (k) values (2), (3)")
await db.commit()
while True:
yield
async with aiosqlite.connect(path):
pass
@timed
async def test_atomics(self):
async with aiosqlite.connect(TEST_DB) as db:
await db.execute("create table perf (i integer primary key asc, k integer)")
await db.execute("insert into perf (k) values (2), (3)")
await db.commit()
while True:
yield
async with db.execute("select last_insert_rowid()") as cursor:
await cursor.fetchone()
@timed
async def test_inserts(self):
async with aiosqlite.connect(TEST_DB) as db:
await db.execute("create table perf (i integer primary key asc, k integer)")
await db.commit()
while True:
yield
await db.execute("insert into perf (k) values (1), (2), (3)")
await db.commit()
@timed
async def test_inserts_authorized(self):
def deny_drops(action_code, arg1, arg2, db_name, trigger_name):
if action_code == sqlite3.SQLITE_DROP_TABLE:
return sqlite3.SQLITE_DENY
return sqlite3.SQLITE_OK
async with aiosqlite.connect(TEST_DB) as db:
await db.execute("create table perf (i integer primary key asc, k integer)")
await db.set_authorizer(deny_drops)
await db.commit()
while True:
yield
await db.execute("insert into perf (k) values (1), (2), (3)")
await db.commit()
@timed
async def test_insert_ids(self):
async with aiosqlite.connect(TEST_DB) as db:
await db.execute("create table perf (i integer primary key asc, k integer)")
await db.commit()
while True:
yield
cursor = await db.execute("insert into perf (k) values (1)")
await cursor.execute("select last_insert_rowid()")
await cursor.fetchone()
await db.commit()
@timed
async def test_insert_macro_ids(self):
async with aiosqlite.connect(TEST_DB) as db:
await db.execute("create table perf (i integer primary key asc, k integer)")
await db.commit()
while True:
yield
await db.execute_insert("insert into perf (k) values (1)")
await db.commit()
@timed
async def test_select(self):
async with aiosqlite.connect(TEST_DB) as db:
await db.execute("create table perf (i integer primary key asc, k integer)")
for i in range(100):
await db.execute("insert into perf (k) values (%d)" % (i,))
await db.commit()
while True:
yield
cursor = await db.execute("select i, k from perf")
assert len(await cursor.fetchall()) == 100
@timed
async def test_select_macro(self):
async with aiosqlite.connect(TEST_DB) as db:
await db.execute("create table perf (i integer primary key asc, k integer)")
for i in range(100):
await db.execute("insert into perf (k) values (%d)" % (i,))
await db.commit()
while True:
yield
assert len(await db.execute_fetchall("select i, k from perf")) == 100
async def test_iterable_cursor_perf(self):
async with aiosqlite.connect(TEST_DB) as db:
await db.execute(
"create table ic_perf ("
"i integer primary key asc, k integer, a integer, b integer, c char(16))"
)
for batch in range(128): # add 128k rows
r_start = batch * 1024
await db.executemany(
"insert into ic_perf (k, a, b, c) values(?, 1, 2, ?)",
[
*[
(i, string.ascii_lowercase)
for i in range(r_start, r_start + 1024)
]
],
)
await db.commit()
async def test_perf(chunk_size: int):
while True:
async with db.execute("SELECT * FROM ic_perf") as cursor:
cursor.iter_chunk_size = chunk_size
async for _ in cursor:
yield
for chunk_size in [2**i for i in range(4, 11)]:
await timed(test_perf, f"iterable_cursor @ {chunk_size}")(chunk_size)

View File

@@ -0,0 +1,537 @@
# Copyright Amethyst Reese
# Licensed under the MIT license
import asyncio
import sqlite3
import sys
from pathlib import Path
from sqlite3 import OperationalError
from tempfile import TemporaryDirectory
from threading import Thread
from unittest import IsolatedAsyncioTestCase, SkipTest
from unittest.mock import patch
import aiosqlite
from .helpers import setup_logger
class SmokeTest(IsolatedAsyncioTestCase):
@classmethod
def setUpClass(cls):
setup_logger()
def setUp(self):
td = TemporaryDirectory()
self.addCleanup(td.cleanup)
self.db = Path(td.name).resolve() / "test.db"
async def test_connection_await(self):
db = await aiosqlite.connect(self.db)
self.assertIsInstance(db, aiosqlite.Connection)
async with db.execute("select 1, 2") as cursor:
rows = await cursor.fetchall()
self.assertEqual(rows, [(1, 2)])
await db.close()
async def test_connection_context(self):
async with aiosqlite.connect(self.db) as db:
self.assertIsInstance(db, aiosqlite.Connection)
async with db.execute("select 1, 2") as cursor:
rows = await cursor.fetchall()
self.assertEqual(rows, [(1, 2)])
async def test_connection_locations(self):
TEST_DB = self.db.as_posix()
class Fake: # pylint: disable=too-few-public-methods
def __str__(self):
return TEST_DB
locs = (Path(TEST_DB), TEST_DB, TEST_DB.encode(), Fake())
async with aiosqlite.connect(locs[0]) as db:
await db.execute("create table foo (i integer, k integer)")
await db.execute("insert into foo (i, k) values (1, 5)")
await db.commit()
cursor = await db.execute("select * from foo")
rows = await cursor.fetchall()
for loc in locs:
async with aiosqlite.connect(loc) as db:
cursor = await db.execute("select * from foo")
self.assertEqual(await cursor.fetchall(), rows)
async def test_multiple_connections(self):
async with aiosqlite.connect(self.db) as db:
await db.execute(
"create table multiple_connections "
"(i integer primary key asc, k integer)"
)
async def do_one_conn(i):
async with aiosqlite.connect(self.db) as db:
await db.execute("insert into multiple_connections (k) values (?)", [i])
await db.commit()
await asyncio.gather(*[do_one_conn(i) for i in range(10)])
async with aiosqlite.connect(self.db) as db:
cursor = await db.execute("select * from multiple_connections")
rows = await cursor.fetchall()
assert len(rows) == 10
async def test_multiple_queries(self):
async with aiosqlite.connect(self.db) as db:
await db.execute(
"create table multiple_queries "
"(i integer primary key asc, k integer)"
)
await asyncio.gather(
*[
db.execute("insert into multiple_queries (k) values (?)", [i])
for i in range(10)
]
)
await db.commit()
async with aiosqlite.connect(self.db) as db:
cursor = await db.execute("select * from multiple_queries")
rows = await cursor.fetchall()
assert len(rows) == 10
async def test_iterable_cursor(self):
async with aiosqlite.connect(self.db) as db:
cursor = await db.cursor()
await cursor.execute(
"create table iterable_cursor " "(i integer primary key asc, k integer)"
)
await cursor.executemany(
"insert into iterable_cursor (k) values (?)", [[i] for i in range(10)]
)
await db.commit()
async with aiosqlite.connect(self.db) as db:
cursor = await db.execute("select * from iterable_cursor")
rows = []
async for row in cursor:
rows.append(row)
assert len(rows) == 10
async def test_multi_loop_usage(self):
results = {}
def runner(k, conn):
async def query():
async with conn.execute("select * from foo") as cursor:
rows = await cursor.fetchall()
self.assertEqual(len(rows), 2)
return rows
with self.subTest(k):
loop = asyncio.new_event_loop()
rows = loop.run_until_complete(query())
loop.close()
results[k] = rows
async with aiosqlite.connect(":memory:") as db:
await db.execute("create table foo (id int, name varchar)")
await db.execute(
"insert into foo values (?, ?), (?, ?)", (1, "Sally", 2, "Janet")
)
await db.commit()
threads = [Thread(target=runner, args=(k, db)) for k in range(4)]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
self.assertEqual(len(results), 4)
for rows in results.values():
self.assertEqual(len(rows), 2)
async def test_context_cursor(self):
async with aiosqlite.connect(self.db) as db:
async with db.cursor() as cursor:
await cursor.execute(
"create table context_cursor "
"(i integer primary key asc, k integer)"
)
await cursor.executemany(
"insert into context_cursor (k) values (?)",
[[i] for i in range(10)],
)
await db.commit()
async with aiosqlite.connect(self.db) as db:
async with db.execute("select * from context_cursor") as cursor:
rows = []
async for row in cursor:
rows.append(row)
assert len(rows) == 10
async def test_cursor_return_self(self):
async with aiosqlite.connect(self.db) as db:
cursor = await db.cursor()
result = await cursor.execute(
"create table test_cursor_return_self (i integer, k integer)"
)
self.assertEqual(result, cursor, "cursor execute returns itself")
result = await cursor.executemany(
"insert into test_cursor_return_self values (?, ?)", [(1, 1), (2, 2)]
)
self.assertEqual(result, cursor)
result = await cursor.executescript(
"insert into test_cursor_return_self values (3, 3);"
"insert into test_cursor_return_self values (4, 4);"
"insert into test_cursor_return_self values (5, 5);"
)
self.assertEqual(result, cursor)
async def test_connection_properties(self):
async with aiosqlite.connect(self.db) as db:
self.assertEqual(db.total_changes, 0)
async with db.cursor() as cursor:
self.assertFalse(db.in_transaction)
await cursor.execute(
"create table test_properties "
"(i integer primary key asc, k integer, d text)"
)
await cursor.execute(
"insert into test_properties (k, d) values (1, 'hi')"
)
self.assertTrue(db.in_transaction)
await db.commit()
self.assertFalse(db.in_transaction)
self.assertEqual(db.total_changes, 1)
self.assertIsNone(db.row_factory)
self.assertEqual(db.text_factory, str)
async with db.cursor() as cursor:
await cursor.execute("select * from test_properties")
row = await cursor.fetchone()
self.assertIsInstance(row, tuple)
self.assertEqual(row, (1, 1, "hi"))
with self.assertRaises(TypeError):
_ = row["k"]
async with db.cursor() as cursor:
cursor.row_factory = aiosqlite.Row
self.assertEqual(cursor.row_factory, aiosqlite.Row)
await cursor.execute("select * from test_properties")
row = await cursor.fetchone()
self.assertIsInstance(row, aiosqlite.Row)
self.assertEqual(row[1], 1)
self.assertEqual(row[2], "hi")
self.assertEqual(row["k"], 1)
self.assertEqual(row["d"], "hi")
db.row_factory = aiosqlite.Row
db.text_factory = bytes
self.assertEqual(db.row_factory, aiosqlite.Row)
self.assertEqual(db.text_factory, bytes)
async with db.cursor() as cursor:
await cursor.execute("select * from test_properties")
row = await cursor.fetchone()
self.assertIsInstance(row, aiosqlite.Row)
self.assertEqual(row[1], 1)
self.assertEqual(row[2], b"hi")
self.assertEqual(row["k"], 1)
self.assertEqual(row["d"], b"hi")
async def test_fetch_all(self):
async with aiosqlite.connect(self.db) as db:
await db.execute(
"create table test_fetch_all (i integer primary key asc, k integer)"
)
await db.execute(
"insert into test_fetch_all (k) values (10), (24), (16), (32)"
)
await db.commit()
async with aiosqlite.connect(self.db) as db:
cursor = await db.execute("select k from test_fetch_all where k < 30")
rows = await cursor.fetchall()
self.assertEqual(rows, [(10,), (24,), (16,)])
async def test_enable_load_extension(self):
"""Assert that after enabling extension loading, they can be loaded"""
async with aiosqlite.connect(self.db) as db:
try:
await db.enable_load_extension(True)
await db.load_extension("test")
except OperationalError as e:
assert "not authorized" not in e.args
except AttributeError as e:
raise SkipTest(
"python was not compiled with sqlite3 "
"extension support, so we can't test it"
) from e
async def test_set_progress_handler(self):
"""
Assert that after setting a progress handler returning 1, DB operations are aborted
"""
async with aiosqlite.connect(self.db) as db:
await db.set_progress_handler(lambda: 1, 1)
with self.assertRaises(OperationalError):
await db.execute(
"create table test_progress_handler (i integer primary key asc, k integer)"
)
async def test_create_function(self):
"""Assert that after creating a custom function, it can be used"""
def no_arg():
return "no arg"
def one_arg(num):
return num * 2
async with aiosqlite.connect(self.db) as db:
await db.create_function("no_arg", 0, no_arg)
await db.create_function("one_arg", 1, one_arg)
async with db.execute("SELECT no_arg();") as res:
row = await res.fetchone()
self.assertEqual(row[0], "no arg")
async with db.execute("SELECT one_arg(10);") as res:
row = await res.fetchone()
self.assertEqual(row[0], 20)
async def test_create_function_deterministic(self):
"""Assert that after creating a deterministic custom function, it can be used.
https://sqlite.org/deterministic.html
"""
def one_arg(num):
return num * 2
async with aiosqlite.connect(self.db) as db:
await db.create_function("one_arg", 1, one_arg, deterministic=True)
await db.execute("create table foo (id int, bar int)")
# Non-deterministic functions cannot be used in indexes
await db.execute("create index t on foo(one_arg(bar))")
async def test_set_trace_callback(self):
statements = []
def callback(statement: str):
statements.append(statement)
async with aiosqlite.connect(self.db) as db:
await db.set_trace_callback(callback)
await db.execute("select 10")
self.assertIn("select 10", statements)
async def test_set_authorizer_deny_drops(self):
"""Test authorizer that denies DROP operations"""
def deny_drops(action_code, arg1, arg2, db_name, trigger_name):
if action_code == sqlite3.SQLITE_DROP_TABLE:
return sqlite3.SQLITE_DENY
return sqlite3.SQLITE_OK
async with aiosqlite.connect(self.db) as db:
await db.set_authorizer(deny_drops)
# Other operations should succeed
await db.execute("CREATE TABLE test_drop (id INTEGER)")
await db.execute("INSERT INTO test_drop VALUES (1)")
await db.execute("SELECT * FROM test_drop")
# DROP should fail
with self.assertRaises(sqlite3.DatabaseError):
await db.execute("DROP TABLE test_drop")
if sys.version_info >= (3, 11):
# Disabling the authorizer re-enables DROP
await db.set_authorizer(None)
await db.execute("DROP TABLE test_drop")
async def test_set_authorizer_exception_propagation(self):
"""Test that exceptions raised in authorizer callback are caught by SQLite"""
def raise_exception(action_code, arg1, arg2, db_name, trigger_name):
raise ValueError("Test exception from authorizer")
async with aiosqlite.connect(self.db) as db:
await db.set_authorizer(raise_exception)
with self.assertRaises(sqlite3.DatabaseError):
await db.execute("CREATE TABLE test_exception (id INTEGER)")
async def test_connect_error(self):
bad_db = Path("/something/that/shouldnt/exist.db")
with self.assertRaisesRegex(OperationalError, "unable to open database"):
async with aiosqlite.connect(bad_db) as db:
self.assertIsNone(db) # should never be reached
with self.assertRaisesRegex(OperationalError, "unable to open database"):
await aiosqlite.connect(bad_db)
async def test_connect_base_exception(self):
# Check if connect task is cancelled, thread is properly closed.
def _raise_cancelled_error(*_, **__):
raise asyncio.CancelledError("I changed my mind")
connection = aiosqlite.Connection(lambda: sqlite3.connect(":memory:"), 64)
with (
patch.object(sqlite3, "connect", side_effect=_raise_cancelled_error),
self.assertRaisesRegex(asyncio.CancelledError, "I changed my mind"),
):
async with connection:
...
# Terminate the thread here if the test fails to have a clear error.
if connection._running:
connection.stop()
raise AssertionError("connection thread was not stopped")
async def test_iterdump(self):
async with aiosqlite.connect(":memory:") as db:
await db.execute("create table foo (i integer, k charvar(250))")
await db.executemany(
"insert into foo values (?, ?)", [(1, "hello"), (2, "world")]
)
lines = [line async for line in db.iterdump()]
self.assertEqual(
lines,
[
"BEGIN TRANSACTION;",
"CREATE TABLE foo (i integer, k charvar(250));",
"INSERT INTO \"foo\" VALUES(1,'hello');",
"INSERT INTO \"foo\" VALUES(2,'world');",
"COMMIT;",
],
)
async def test_cursor_on_closed_connection(self):
db = await aiosqlite.connect(self.db)
cursor = await db.execute("select 1, 2")
await db.close()
with self.assertRaisesRegex(ValueError, "Connection closed"):
await cursor.fetchall()
with self.assertRaisesRegex(ValueError, "Connection closed"):
await cursor.fetchall()
async def test_cursor_on_closed_connection_loop(self):
db = await aiosqlite.connect(self.db)
cursor = await db.execute("select 1, 2")
tasks = []
for i in range(100):
if i == 50:
tasks.append(asyncio.ensure_future(db.close()))
tasks.append(asyncio.ensure_future(cursor.fetchall()))
for task in tasks:
try:
await task
except sqlite3.ProgrammingError:
pass
async def test_close_blocking_until_transaction_queue_empty(self):
db = await aiosqlite.connect(self.db)
# Insert transactions into the
# transaction queue '_tx'
for i in range(1000):
await db.execute(f"select 1, {i}")
# Wait for all transactions to complete
await db.close()
# Check no more transaction pending
self.assertEqual(db._tx.empty(), True)
async def test_close_twice(self):
db = await aiosqlite.connect(self.db)
await db.close()
# no error
await db.close()
async def test_backup_aiosqlite(self):
def progress(a, b, c):
print(a, b, c)
async with (
aiosqlite.connect(":memory:") as db1,
aiosqlite.connect(":memory:") as db2,
):
await db1.execute("create table foo (i integer, k charvar(250))")
await db1.executemany(
"insert into foo values (?, ?)", [(1, "hello"), (2, "world")]
)
await db1.commit()
with self.assertRaisesRegex(OperationalError, "no such table: foo"):
await db2.execute("select * from foo")
await db1.backup(db2, progress=progress)
async with db2.execute("select * from foo") as cursor:
rows = await cursor.fetchall()
self.assertEqual(rows, [(1, "hello"), (2, "world")])
async def test_backup_sqlite(self):
async with aiosqlite.connect(":memory:") as db1:
with sqlite3.connect(":memory:") as db2:
await db1.execute("create table foo (i integer, k charvar(250))")
await db1.executemany(
"insert into foo values (?, ?)", [(1, "hello"), (2, "world")]
)
await db1.commit()
with self.assertRaisesRegex(OperationalError, "no such table: foo"):
db2.execute("select * from foo")
await db1.backup(db2)
cursor = db2.execute("select * from foo")
rows = cursor.fetchall()
self.assertEqual(rows, [(1, "hello"), (2, "world")])
async def test_emits_warning_when_left_open(self):
db = await aiosqlite.connect(":memory:")
with self.assertWarnsRegex(
ResourceWarning, r".*was deleted before being closed.*"
):
del db
async def test_stop_without_close(self):
db = await aiosqlite.connect(":memory:")
await db.stop()
def test_stop_after_event_loop_closed(self):
db = None
async def inner():
nonlocal db
db = await aiosqlite.connect(":memory:")
loop = asyncio.new_event_loop()
loop.run_until_complete(inner())
loop.close()
db.stop()

View File

@@ -0,0 +1,145 @@
Metadata-Version: 2.4
Name: annotated-doc
Version: 0.0.4
Summary: Document parameters, class attributes, return types, and variables inline, with Annotated.
Author-Email: =?utf-8?q?Sebasti=C3=A1n_Ram=C3=ADrez?= <tiangolo@gmail.com>
License-Expression: MIT
License-File: LICENSE
Classifier: Intended Audience :: Information Technology
Classifier: Intended Audience :: System Administrators
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python
Classifier: Topic :: Internet
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Topic :: Software Development :: Libraries
Classifier: Topic :: Software Development
Classifier: Typing :: Typed
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3 :: Only
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
Project-URL: Homepage, https://github.com/fastapi/annotated-doc
Project-URL: Documentation, https://github.com/fastapi/annotated-doc
Project-URL: Repository, https://github.com/fastapi/annotated-doc
Project-URL: Issues, https://github.com/fastapi/annotated-doc/issues
Project-URL: Changelog, https://github.com/fastapi/annotated-doc/release-notes.md
Requires-Python: >=3.8
Description-Content-Type: text/markdown
# Annotated Doc
Document parameters, class attributes, return types, and variables inline, with `Annotated`.
<a href="https://github.com/fastapi/annotated-doc/actions?query=workflow%3ATest+event%3Apush+branch%3Amain" target="_blank">
<img src="https://github.com/fastapi/annotated-doc/actions/workflows/test.yml/badge.svg?event=push&branch=main" alt="Test">
</a>
<a href="https://coverage-badge.samuelcolvin.workers.dev/redirect/fastapi/annotated-doc" target="_blank">
<img src="https://coverage-badge.samuelcolvin.workers.dev/fastapi/annotated-doc.svg" alt="Coverage">
</a>
<a href="https://pypi.org/project/annotated-doc" target="_blank">
<img src="https://img.shields.io/pypi/v/annotated-doc?color=%2334D058&label=pypi%20package" alt="Package version">
</a>
<a href="https://pypi.org/project/annotated-doc" target="_blank">
<img src="https://img.shields.io/pypi/pyversions/annotated-doc.svg?color=%2334D058" alt="Supported Python versions">
</a>
## Installation
```bash
pip install annotated-doc
```
Or with `uv`:
```Python
uv add annotated-doc
```
## Usage
Import `Doc` and pass a single literal string with the documentation for the specific parameter, class attribute, return type, or variable.
For example, to document a parameter `name` in a function `hi` you could do:
```Python
from typing import Annotated
from annotated_doc import Doc
def hi(name: Annotated[str, Doc("Who to say hi to")]) -> None:
print(f"Hi, {name}!")
```
You can also use it to document class attributes:
```Python
from typing import Annotated
from annotated_doc import Doc
class User:
name: Annotated[str, Doc("The user's name")]
age: Annotated[int, Doc("The user's age")]
```
The same way, you could document return types and variables, or anything that could have a type annotation with `Annotated`.
## Who Uses This
`annotated-doc` was made for:
* [FastAPI](https://fastapi.tiangolo.com/)
* [Typer](https://typer.tiangolo.com/)
* [SQLModel](https://sqlmodel.tiangolo.com/)
* [Asyncer](https://asyncer.tiangolo.com/)
`annotated-doc` is supported by [griffe-typingdoc](https://github.com/mkdocstrings/griffe-typingdoc), which powers reference documentation like the one in the [FastAPI Reference](https://fastapi.tiangolo.com/reference/).
## Reasons not to use `annotated-doc`
You are already comfortable with one of the existing docstring formats, like:
* Sphinx
* numpydoc
* Google
* Keras
Your team is already comfortable using them.
You prefer having the documentation about parameters all together in a docstring, separated from the code defining them.
You care about a specific set of users, using one specific editor, and that editor already has support for the specific docstring format you use.
## Reasons to use `annotated-doc`
* No micro-syntax to learn for newcomers, its **just Python** syntax.
* **Editing** would be already fully supported by default by any editor (current or future) supporting Python syntax, including syntax errors, syntax highlighting, etc.
* **Rendering** would be relatively straightforward to implement by static tools (tools that don't need runtime execution), as the information can be extracted from the AST they normally already create.
* **Deduplication of information**: the name of a parameter would be defined in a single place, not duplicated inside of a docstring.
* **Elimination** of the possibility of having **inconsistencies** when removing a parameter or class variable and **forgetting to remove** its documentation.
* **Minimization** of the probability of adding a new parameter or class variable and **forgetting to add its documentation**.
* **Elimination** of the possibility of having **inconsistencies** between the **name** of a parameter in the **signature** and the name in the docstring when it is renamed.
* **Access** to the documentation string for each symbol at **runtime**, including existing (older) Python versions.
* A more formalized way to document other symbols, like type aliases, that could use Annotated.
* **Support** for apps using FastAPI, Typer and others.
* **AI Accessibility**: AI tools will have an easier way understanding each parameter as the distance from documentation to parameter is much closer.
## History
I ([@tiangolo](https://github.com/tiangolo)) originally wanted for this to be part of the Python standard library (in [PEP 727](https://peps.python.org/pep-0727/)), but the proposal was withdrawn as there was a fair amount of negative feedback and opposition.
The conclusion was that this was better done as an external effort, in a third-party library.
So, here it is, with a simpler approach, as a third-party library, in a way that can be used by others, starting with FastAPI and friends.
## License
This project is licensed under the terms of the MIT license.

View File

@@ -0,0 +1,11 @@
annotated_doc-0.0.4.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
annotated_doc-0.0.4.dist-info/METADATA,sha256=Irm5KJua33dY2qKKAjJ-OhKaVBVIfwFGej_dSe3Z1TU,6566
annotated_doc-0.0.4.dist-info/RECORD,,
annotated_doc-0.0.4.dist-info/WHEEL,sha256=9P2ygRxDrTJz3gsagc0Z96ukrxjr-LFBGOgv3AuKlCA,90
annotated_doc-0.0.4.dist-info/entry_points.txt,sha256=6OYgBcLyFCUgeqLgnvMyOJxPCWzgy7se4rLPKtNonMs,34
annotated_doc-0.0.4.dist-info/licenses/LICENSE,sha256=__Fwd5pqy_ZavbQFwIfxzuF4ZpHkqWpANFF-SlBKDN8,1086
annotated_doc/__init__.py,sha256=VuyxxUe80kfEyWnOrCx_Bk8hybo3aKo6RYBlkBBYW8k,52
annotated_doc/__pycache__/__init__.cpython-312.pyc,,
annotated_doc/__pycache__/main.cpython-312.pyc,,
annotated_doc/main.py,sha256=5Zfvxv80SwwLqpRW73AZyZyiM4bWma9QWRbp_cgD20s,1075
annotated_doc/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0

View File

@@ -0,0 +1,4 @@
Wheel-Version: 1.0
Generator: pdm-backend (2.4.5)
Root-Is-Purelib: true
Tag: py3-none-any

View File

@@ -0,0 +1,4 @@
[console_scripts]
[gui_scripts]

View File

@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2025 Sebastián Ramírez
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View File

@@ -0,0 +1,3 @@
from .main import Doc as Doc
__version__ = "0.0.4"

View File

@@ -0,0 +1,36 @@
class Doc:
"""Define the documentation of a type annotation using `Annotated`, to be
used in class attributes, function and method parameters, return values,
and variables.
The value should be a positional-only string literal to allow static tools
like editors and documentation generators to use it.
This complements docstrings.
The string value passed is available in the attribute `documentation`.
Example:
```Python
from typing import Annotated
from annotated_doc import Doc
def hi(name: Annotated[str, Doc("Who to say hi to")]) -> None:
print(f"Hi, {name}!")
```
"""
def __init__(self, documentation: str, /) -> None:
self.documentation = documentation
def __repr__(self) -> str:
return f"Doc({self.documentation!r})"
def __hash__(self) -> int:
return hash(self.documentation)
def __eq__(self, other: object) -> bool:
if not isinstance(other, Doc):
return NotImplemented
return self.documentation == other.documentation

View File

@@ -0,0 +1,295 @@
Metadata-Version: 2.3
Name: annotated-types
Version: 0.7.0
Summary: Reusable constraint types to use with typing.Annotated
Project-URL: Homepage, https://github.com/annotated-types/annotated-types
Project-URL: Source, https://github.com/annotated-types/annotated-types
Project-URL: Changelog, https://github.com/annotated-types/annotated-types/releases
Author-email: Adrian Garcia Badaracco <1755071+adriangb@users.noreply.github.com>, Samuel Colvin <s@muelcolvin.com>, Zac Hatfield-Dodds <zac@zhd.dev>
License-File: LICENSE
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Environment :: MacOS X
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: Information Technology
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: POSIX :: Linux
Classifier: Operating System :: Unix
Classifier: Programming Language :: Python :: 3 :: Only
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: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.8
Requires-Dist: typing-extensions>=4.0.0; python_version < '3.9'
Description-Content-Type: text/markdown
# annotated-types
[![CI](https://github.com/annotated-types/annotated-types/workflows/CI/badge.svg?event=push)](https://github.com/annotated-types/annotated-types/actions?query=event%3Apush+branch%3Amain+workflow%3ACI)
[![pypi](https://img.shields.io/pypi/v/annotated-types.svg)](https://pypi.python.org/pypi/annotated-types)
[![versions](https://img.shields.io/pypi/pyversions/annotated-types.svg)](https://github.com/annotated-types/annotated-types)
[![license](https://img.shields.io/github/license/annotated-types/annotated-types.svg)](https://github.com/annotated-types/annotated-types/blob/main/LICENSE)
[PEP-593](https://peps.python.org/pep-0593/) added `typing.Annotated` as a way of
adding context-specific metadata to existing types, and specifies that
`Annotated[T, x]` _should_ be treated as `T` by any tool or library without special
logic for `x`.
This package provides metadata objects which can be used to represent common
constraints such as upper and lower bounds on scalar values and collection sizes,
a `Predicate` marker for runtime checks, and
descriptions of how we intend these metadata to be interpreted. In some cases,
we also note alternative representations which do not require this package.
## Install
```bash
pip install annotated-types
```
## Examples
```python
from typing import Annotated
from annotated_types import Gt, Len, Predicate
class MyClass:
age: Annotated[int, Gt(18)] # Valid: 19, 20, ...
# Invalid: 17, 18, "19", 19.0, ...
factors: list[Annotated[int, Predicate(is_prime)]] # Valid: 2, 3, 5, 7, 11, ...
# Invalid: 4, 8, -2, 5.0, "prime", ...
my_list: Annotated[list[int], Len(0, 10)] # Valid: [], [10, 20, 30, 40, 50]
# Invalid: (1, 2), ["abc"], [0] * 20
```
## Documentation
_While `annotated-types` avoids runtime checks for performance, users should not
construct invalid combinations such as `MultipleOf("non-numeric")` or `Annotated[int, Len(3)]`.
Downstream implementors may choose to raise an error, emit a warning, silently ignore
a metadata item, etc., if the metadata objects described below are used with an
incompatible type - or for any other reason!_
### Gt, Ge, Lt, Le
Express inclusive and/or exclusive bounds on orderable values - which may be numbers,
dates, times, strings, sets, etc. Note that the boundary value need not be of the
same type that was annotated, so long as they can be compared: `Annotated[int, Gt(1.5)]`
is fine, for example, and implies that the value is an integer x such that `x > 1.5`.
We suggest that implementors may also interpret `functools.partial(operator.le, 1.5)`
as being equivalent to `Gt(1.5)`, for users who wish to avoid a runtime dependency on
the `annotated-types` package.
To be explicit, these types have the following meanings:
* `Gt(x)` - value must be "Greater Than" `x` - equivalent to exclusive minimum
* `Ge(x)` - value must be "Greater than or Equal" to `x` - equivalent to inclusive minimum
* `Lt(x)` - value must be "Less Than" `x` - equivalent to exclusive maximum
* `Le(x)` - value must be "Less than or Equal" to `x` - equivalent to inclusive maximum
### Interval
`Interval(gt, ge, lt, le)` allows you to specify an upper and lower bound with a single
metadata object. `None` attributes should be ignored, and non-`None` attributes
treated as per the single bounds above.
### MultipleOf
`MultipleOf(multiple_of=x)` might be interpreted in two ways:
1. Python semantics, implying `value % multiple_of == 0`, or
2. [JSONschema semantics](https://json-schema.org/draft/2020-12/json-schema-validation.html#rfc.section.6.2.1),
where `int(value / multiple_of) == value / multiple_of`.
We encourage users to be aware of these two common interpretations and their
distinct behaviours, especially since very large or non-integer numbers make
it easy to cause silent data corruption due to floating-point imprecision.
We encourage libraries to carefully document which interpretation they implement.
### MinLen, MaxLen, Len
`Len()` implies that `min_length <= len(value) <= max_length` - lower and upper bounds are inclusive.
As well as `Len()` which can optionally include upper and lower bounds, we also
provide `MinLen(x)` and `MaxLen(y)` which are equivalent to `Len(min_length=x)`
and `Len(max_length=y)` respectively.
`Len`, `MinLen`, and `MaxLen` may be used with any type which supports `len(value)`.
Examples of usage:
* `Annotated[list, MaxLen(10)]` (or `Annotated[list, Len(max_length=10))`) - list must have a length of 10 or less
* `Annotated[str, MaxLen(10)]` - string must have a length of 10 or less
* `Annotated[list, MinLen(3))` (or `Annotated[list, Len(min_length=3))`) - list must have a length of 3 or more
* `Annotated[list, Len(4, 6)]` - list must have a length of 4, 5, or 6
* `Annotated[list, Len(8, 8)]` - list must have a length of exactly 8
#### Changed in v0.4.0
* `min_inclusive` has been renamed to `min_length`, no change in meaning
* `max_exclusive` has been renamed to `max_length`, upper bound is now **inclusive** instead of **exclusive**
* The recommendation that slices are interpreted as `Len` has been removed due to ambiguity and different semantic
meaning of the upper bound in slices vs. `Len`
See [issue #23](https://github.com/annotated-types/annotated-types/issues/23) for discussion.
### Timezone
`Timezone` can be used with a `datetime` or a `time` to express which timezones
are allowed. `Annotated[datetime, Timezone(None)]` must be a naive datetime.
`Timezone[...]` ([literal ellipsis](https://docs.python.org/3/library/constants.html#Ellipsis))
expresses that any timezone-aware datetime is allowed. You may also pass a specific
timezone string or [`tzinfo`](https://docs.python.org/3/library/datetime.html#tzinfo-objects)
object such as `Timezone(timezone.utc)` or `Timezone("Africa/Abidjan")` to express that you only
allow a specific timezone, though we note that this is often a symptom of fragile design.
#### Changed in v0.x.x
* `Timezone` accepts [`tzinfo`](https://docs.python.org/3/library/datetime.html#tzinfo-objects) objects instead of
`timezone`, extending compatibility to [`zoneinfo`](https://docs.python.org/3/library/zoneinfo.html) and third party libraries.
### Unit
`Unit(unit: str)` expresses that the annotated numeric value is the magnitude of
a quantity with the specified unit. For example, `Annotated[float, Unit("m/s")]`
would be a float representing a velocity in meters per second.
Please note that `annotated_types` itself makes no attempt to parse or validate
the unit string in any way. That is left entirely to downstream libraries,
such as [`pint`](https://pint.readthedocs.io) or
[`astropy.units`](https://docs.astropy.org/en/stable/units/).
An example of how a library might use this metadata:
```python
from annotated_types import Unit
from typing import Annotated, TypeVar, Callable, Any, get_origin, get_args
# given a type annotated with a unit:
Meters = Annotated[float, Unit("m")]
# you can cast the annotation to a specific unit type with any
# callable that accepts a string and returns the desired type
T = TypeVar("T")
def cast_unit(tp: Any, unit_cls: Callable[[str], T]) -> T | None:
if get_origin(tp) is Annotated:
for arg in get_args(tp):
if isinstance(arg, Unit):
return unit_cls(arg.unit)
return None
# using `pint`
import pint
pint_unit = cast_unit(Meters, pint.Unit)
# using `astropy.units`
import astropy.units as u
astropy_unit = cast_unit(Meters, u.Unit)
```
### Predicate
`Predicate(func: Callable)` expresses that `func(value)` is truthy for valid values.
Users should prefer the statically inspectable metadata above, but if you need
the full power and flexibility of arbitrary runtime predicates... here it is.
For some common constraints, we provide generic types:
* `IsLower = Annotated[T, Predicate(str.islower)]`
* `IsUpper = Annotated[T, Predicate(str.isupper)]`
* `IsDigit = Annotated[T, Predicate(str.isdigit)]`
* `IsFinite = Annotated[T, Predicate(math.isfinite)]`
* `IsNotFinite = Annotated[T, Predicate(Not(math.isfinite))]`
* `IsNan = Annotated[T, Predicate(math.isnan)]`
* `IsNotNan = Annotated[T, Predicate(Not(math.isnan))]`
* `IsInfinite = Annotated[T, Predicate(math.isinf)]`
* `IsNotInfinite = Annotated[T, Predicate(Not(math.isinf))]`
so that you can write e.g. `x: IsFinite[float] = 2.0` instead of the longer
(but exactly equivalent) `x: Annotated[float, Predicate(math.isfinite)] = 2.0`.
Some libraries might have special logic to handle known or understandable predicates,
for example by checking for `str.isdigit` and using its presence to both call custom
logic to enforce digit-only strings, and customise some generated external schema.
Users are therefore encouraged to avoid indirection like `lambda s: s.lower()`, in
favor of introspectable methods such as `str.lower` or `re.compile("pattern").search`.
To enable basic negation of commonly used predicates like `math.isnan` without introducing introspection that makes it impossible for implementers to introspect the predicate we provide a `Not` wrapper that simply negates the predicate in an introspectable manner. Several of the predicates listed above are created in this manner.
We do not specify what behaviour should be expected for predicates that raise
an exception. For example `Annotated[int, Predicate(str.isdigit)]` might silently
skip invalid constraints, or statically raise an error; or it might try calling it
and then propagate or discard the resulting
`TypeError: descriptor 'isdigit' for 'str' objects doesn't apply to a 'int' object`
exception. We encourage libraries to document the behaviour they choose.
### Doc
`doc()` can be used to add documentation information in `Annotated`, for function and method parameters, variables, class attributes, return types, and any place where `Annotated` can be used.
It expects a value that can be statically analyzed, as the main use case is for static analysis, editors, documentation generators, and similar tools.
It returns a `DocInfo` class with a single attribute `documentation` containing the value passed to `doc()`.
This is the early adopter's alternative form of the [`typing-doc` proposal](https://github.com/tiangolo/fastapi/blob/typing-doc/typing_doc.md).
### Integrating downstream types with `GroupedMetadata`
Implementers may choose to provide a convenience wrapper that groups multiple pieces of metadata.
This can help reduce verbosity and cognitive overhead for users.
For example, an implementer like Pydantic might provide a `Field` or `Meta` type that accepts keyword arguments and transforms these into low-level metadata:
```python
from dataclasses import dataclass
from typing import Iterator
from annotated_types import GroupedMetadata, Ge
@dataclass
class Field(GroupedMetadata):
ge: int | None = None
description: str | None = None
def __iter__(self) -> Iterator[object]:
# Iterating over a GroupedMetadata object should yield annotated-types
# constraint metadata objects which describe it as fully as possible,
# and may include other unknown objects too.
if self.ge is not None:
yield Ge(self.ge)
if self.description is not None:
yield Description(self.description)
```
Libraries consuming annotated-types constraints should check for `GroupedMetadata` and unpack it by iterating over the object and treating the results as if they had been "unpacked" in the `Annotated` type. The same logic should be applied to the [PEP 646 `Unpack` type](https://peps.python.org/pep-0646/), so that `Annotated[T, Field(...)]`, `Annotated[T, Unpack[Field(...)]]` and `Annotated[T, *Field(...)]` are all treated consistently.
Libraries consuming annotated-types should also ignore any metadata they do not recongize that came from unpacking a `GroupedMetadata`, just like they ignore unrecognized metadata in `Annotated` itself.
Our own `annotated_types.Interval` class is a `GroupedMetadata` which unpacks itself into `Gt`, `Lt`, etc., so this is not an abstract concern. Similarly, `annotated_types.Len` is a `GroupedMetadata` which unpacks itself into `MinLen` (optionally) and `MaxLen`.
### Consuming metadata
We intend to not be prescriptive as to _how_ the metadata and constraints are used, but as an example of how one might parse constraints from types annotations see our [implementation in `test_main.py`](https://github.com/annotated-types/annotated-types/blob/f59cf6d1b5255a0fe359b93896759a180bec30ae/tests/test_main.py#L94-L103).
It is up to the implementer to determine how this metadata is used.
You could use the metadata for runtime type checking, for generating schemas or to generate example data, amongst other use cases.
## Design & History
This package was designed at the PyCon 2022 sprints by the maintainers of Pydantic
and Hypothesis, with the goal of making it as easy as possible for end-users to
provide more informative annotations for use by runtime libraries.
It is deliberately minimal, and following PEP-593 allows considerable downstream
discretion in what (if anything!) they choose to support. Nonetheless, we expect
that staying simple and covering _only_ the most common use-cases will give users
and maintainers the best experience we can. If you'd like more constraints for your
types - follow our lead, by defining them and documenting them downstream!

View File

@@ -0,0 +1,10 @@
annotated_types-0.7.0.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
annotated_types-0.7.0.dist-info/METADATA,sha256=7ltqxksJJ0wCYFGBNIQCWTlWQGeAH0hRFdnK3CB895E,15046
annotated_types-0.7.0.dist-info/RECORD,,
annotated_types-0.7.0.dist-info/WHEEL,sha256=zEMcRr9Kr03x1ozGwg5v9NQBKn3kndp6LSoSlVg-jhU,87
annotated_types-0.7.0.dist-info/licenses/LICENSE,sha256=_hBJiEsaDZNCkB6I4H8ykl0ksxIdmXK2poBfuYJLCV0,1083
annotated_types/__init__.py,sha256=RynLsRKUEGI0KimXydlD1fZEfEzWwDo0Uon3zOKhG1Q,13819
annotated_types/__pycache__/__init__.cpython-312.pyc,,
annotated_types/__pycache__/test_cases.cpython-312.pyc,,
annotated_types/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
annotated_types/test_cases.py,sha256=zHFX6EpcMbGJ8FzBYDbO56bPwx_DYIVSKbZM-4B3_lg,6421

View File

@@ -0,0 +1,4 @@
Wheel-Version: 1.0
Generator: hatchling 1.24.2
Root-Is-Purelib: true
Tag: py3-none-any

View File

@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2022 the contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

Some files were not shown because too many files have changed in this diff Show More