chore: Initialized project virtual environment, installed dependencies, and added basic template and static files.
This commit is contained in:
BIN
.sync.ffs_db
Normal file
BIN
.sync.ffs_db
Normal file
Binary file not shown.
BIN
__pycache__/database.cpython-312.pyc
Normal file
BIN
__pycache__/database.cpython-312.pyc
Normal file
Binary file not shown.
BIN
__pycache__/main.cpython-312.pyc
Normal file
BIN
__pycache__/main.cpython-312.pyc
Normal file
Binary file not shown.
40
database.py
Normal file
40
database.py
Normal 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
BIN
gtravel.db
Normal file
Binary file not shown.
307
main.py
Normal file
307
main.py
Normal 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
5
requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
flask
|
||||
sqlalchemy
|
||||
jinja2
|
||||
requests
|
||||
gunicorn
|
||||
51
seed.py
Normal file
51
seed.py
Normal 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
479
static/css/style.css
Normal 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
BIN
static/images/logo.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
static/images/logo.png
Normal file
BIN
static/images/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
BIN
static/images/mayan_ruins_tikal.png
Normal file
BIN
static/images/mayan_ruins_tikal.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 685 KiB |
BIN
static/images/surf_el_paredon.png
Normal file
BIN
static/images/surf_el_paredon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 767 KiB |
BIN
static/images/volcano_fuego.png
Normal file
BIN
static/images/volcano_fuego.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 702 KiB |
BIN
static/videos/35423-407130876.mp4
Normal file
BIN
static/videos/35423-407130876.mp4
Normal file
Binary file not shown.
71
templates/base.html
Normal file
71
templates/base.html
Normal 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
282
templates/dashboard.html
Normal 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
102
templates/detail.html
Normal 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;">
|
||||
← {{ '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
156
templates/index.html
Normal 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
247
venv/bin/Activate.ps1
Normal 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
70
venv/bin/activate
Normal 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
27
venv/bin/activate.csh
Normal 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
69
venv/bin/activate.fish
Normal 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
8
venv/bin/dotenv
Executable 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
8
venv/bin/fastapi
Executable 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
8
venv/bin/flask
Executable 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
8
venv/bin/gunicorn
Executable 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
8
venv/bin/gunicornc
Executable 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
8
venv/bin/normalizer
Executable 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
8
venv/bin/pip
Executable 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
8
venv/bin/pip3
Executable 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
8
venv/bin/pip3.12
Executable 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
1
venv/bin/python
Symbolic link
@@ -0,0 +1 @@
|
||||
python3
|
||||
1
venv/bin/python3
Symbolic link
1
venv/bin/python3
Symbolic link
@@ -0,0 +1 @@
|
||||
/usr/bin/python3
|
||||
1
venv/bin/python3.12
Symbolic link
1
venv/bin/python3.12
Symbolic link
@@ -0,0 +1 @@
|
||||
python3
|
||||
8
venv/bin/uvicorn
Executable file
8
venv/bin/uvicorn
Executable 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())
|
||||
164
venv/include/site/python3.12/greenlet/greenlet.h
Normal file
164
venv/include/site/python3.12/greenlet/greenlet.h
Normal 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 */
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
||||
pip
|
||||
@@ -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/)
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
Wheel-Version: 1.0
|
||||
Generator: pdm-backend (2.4.4)
|
||||
Root-Is-Purelib: true
|
||||
Tag: py3-none-any
|
||||
@@ -0,0 +1,4 @@
|
||||
[console_scripts]
|
||||
|
||||
[gui_scripts]
|
||||
|
||||
@@ -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.
|
||||
8
venv/lib/python3.12/site-packages/a2wsgi/__init__.py
Normal file
8
venv/lib/python3.12/site-packages/a2wsgi/__init__.py
Normal 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")
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
303
venv/lib/python3.12/site-packages/a2wsgi/asgi.py
Normal file
303
venv/lib/python3.12/site-packages/a2wsgi/asgi.py
Normal 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""
|
||||
182
venv/lib/python3.12/site-packages/a2wsgi/asgi_typing.py
Normal file
182
venv/lib/python3.12/site-packages/a2wsgi/asgi_typing.py
Normal 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]]
|
||||
0
venv/lib/python3.12/site-packages/a2wsgi/py.typed
Normal file
0
venv/lib/python3.12/site-packages/a2wsgi/py.typed
Normal file
265
venv/lib/python3.12/site-packages/a2wsgi/wsgi.py
Normal file
265
venv/lib/python3.12/site-packages/a2wsgi/wsgi.py
Normal 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)()
|
||||
194
venv/lib/python3.12/site-packages/a2wsgi/wsgi_typing.py
Normal file
194
venv/lib/python3.12/site-packages/a2wsgi/wsgi_typing.py
Normal 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 URL’s “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 URL’s “path”, designating the virtual
|
||||
# “location” of the request’s 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]
|
||||
@@ -0,0 +1 @@
|
||||
pip
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
Wheel-Version: 1.0
|
||||
Generator: flit 3.12.0
|
||||
Root-Is-Purelib: true
|
||||
Tag: py3-none-any
|
||||
@@ -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.
|
||||
44
venv/lib/python3.12/site-packages/aiosqlite/__init__.py
Normal file
44
venv/lib/python3.12/site-packages/aiosqlite/__init__.py
Normal 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",
|
||||
]
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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"
|
||||
56
venv/lib/python3.12/site-packages/aiosqlite/context.py
Normal file
56
venv/lib/python3.12/site-packages/aiosqlite/context.py
Normal 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
|
||||
468
venv/lib/python3.12/site-packages/aiosqlite/core.py
Normal file
468
venv/lib/python3.12/site-packages/aiosqlite/core.py
Normal 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)
|
||||
110
venv/lib/python3.12/site-packages/aiosqlite/cursor.py
Normal file
110
venv/lib/python3.12/site-packages/aiosqlite/cursor.py
Normal 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()
|
||||
@@ -0,0 +1,4 @@
|
||||
# Copyright Amethyst Reese
|
||||
# Licensed under the MIT license
|
||||
|
||||
from .smoke import SmokeTest
|
||||
@@ -0,0 +1,7 @@
|
||||
# Copyright Amethyst Reese
|
||||
# Licensed under the MIT license
|
||||
|
||||
import unittest
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(module="aiosqlite.tests", verbosity=2)
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
29
venv/lib/python3.12/site-packages/aiosqlite/tests/helpers.py
Normal file
29
venv/lib/python3.12/site-packages/aiosqlite/tests/helpers.py
Normal 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
|
||||
221
venv/lib/python3.12/site-packages/aiosqlite/tests/perf.py
Normal file
221
venv/lib/python3.12/site-packages/aiosqlite/tests/perf.py
Normal 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)
|
||||
537
venv/lib/python3.12/site-packages/aiosqlite/tests/smoke.py
Normal file
537
venv/lib/python3.12/site-packages/aiosqlite/tests/smoke.py
Normal 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()
|
||||
@@ -0,0 +1 @@
|
||||
pip
|
||||
@@ -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, it’s **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.
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
Wheel-Version: 1.0
|
||||
Generator: pdm-backend (2.4.5)
|
||||
Root-Is-Purelib: true
|
||||
Tag: py3-none-any
|
||||
@@ -0,0 +1,4 @@
|
||||
[console_scripts]
|
||||
|
||||
[gui_scripts]
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,3 @@
|
||||
from .main import Doc as Doc
|
||||
|
||||
__version__ = "0.0.4"
|
||||
Binary file not shown.
Binary file not shown.
36
venv/lib/python3.12/site-packages/annotated_doc/main.py
Normal file
36
venv/lib/python3.12/site-packages/annotated_doc/main.py
Normal 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
|
||||
@@ -0,0 +1 @@
|
||||
pip
|
||||
@@ -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
|
||||
|
||||
[](https://github.com/annotated-types/annotated-types/actions?query=event%3Apush+branch%3Amain+workflow%3ACI)
|
||||
[](https://pypi.python.org/pypi/annotated-types)
|
||||
[](https://github.com/annotated-types/annotated-types)
|
||||
[](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!
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
Wheel-Version: 1.0
|
||||
Generator: hatchling 1.24.2
|
||||
Root-Is-Purelib: true
|
||||
Tag: py3-none-any
|
||||
@@ -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
Reference in New Issue
Block a user