diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index bdb774f..eacc348 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -38,5 +38,8 @@ jobs: run: | uv run playwright install --with-deps chromium + - name: Run linter + run: uv run ruff check . --fix + - name: Run pytest run: uv run pytest -v --maxfail=3 --disable-warnings diff --git a/app.py b/app.py index 68c1953..7c0a7f1 100644 --- a/app.py +++ b/app.py @@ -1,21 +1,35 @@ -from datetime import datetime, timedelta +import hashlib import os +import uuid +from datetime import datetime, timedelta + import pytz import supabase +from dateutil import parser from dotenv import load_dotenv -from dateutil import parser -from flask import Flask, redirect, render_template, request, session, url_for, jsonify, make_response, flash, send_from_directory +from flask import ( + Flask, + flash, + jsonify, + make_response, + redirect, + render_template, + request, + send_from_directory, + session, + url_for, +) from markupsafe import Markup from werkzeug.utils import secure_filename -import hashlib -import uuid + from worker import Worker + def get_local_timestamp(): - local_tz = pytz.timezone('Asia/Karachi') + local_tz = pytz.timezone("Asia/Karachi") utc_now = datetime.now(pytz.utc) local_now = utc_now.astimezone(local_tz) - return local_now.strftime('%Y-%m-%d %H:%M:%S') + return local_now.strftime("%Y-%m-%d %H:%M:%S") app = Flask(__name__) @@ -24,40 +38,78 @@ def get_local_timestamp(): load_dotenv() -ADMIN_USERNAME = os.getenv('ADMIN_USERNAME') -ADMIN_PASSWORD = os.getenv('ADMIN_PASSWORD') -SUPABASE_URL = os.getenv('SUPABASE_URL') -# Prefer a server-side service role key for server operations (bypasses RLS). Fall back to anon key. +ADMIN_USERNAME = os.getenv("ADMIN_USERNAME") +ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD") +SUPABASE_URL = os.getenv("SUPABASE_URL") +# Prefer a server-side service role key for server operations (bypasses RLS). +# Fall back to anon key. SUPABASE_KEY = ( - os.getenv('SUPABASE_SERVICE_ROLE_KEY') or - os.getenv('SUPERBASE_SERVICE_ROLE_KEY') or - os.getenv('SUPERBASE_ANON_KEY') or - os.getenv('SUPABASE_ANON_KEY') + os.getenv("SUPABASE_SERVICE_ROLE_KEY") + or os.getenv("SUPERBASE_SERVICE_ROLE_KEY") + or os.getenv("SUPERBASE_ANON_KEY") + or os.getenv("SUPABASE_ANON_KEY") ) -if os.getenv('SUPABASE_SERVICE_ROLE_KEY') or os.getenv('SUPERBASE_SERVICE_ROLE_KEY'): - print("WARNING: Using Supabase service role key for server operations. Keep this secret and never expose it to browsers.") -BLOG_IMAGES_BUCKET = os.getenv('BLOG_IMAGES_BUCKET') or os.getenv('SUPERBASE_IMAGES_BUCKET_NAME') or 'blog_images' -BLOG_VIDEOS_BUCKET = os.getenv('BLOG_VIDEOS_BUCKET') or os.getenv('SUPERBASE_VIDEOS_BUCKET_NAME') or 'blog_videos' +if os.getenv("SUPABASE_SERVICE_ROLE_KEY") or os.getenv("SUPERBASE_SERVICE_ROLE_KEY"): + print( + "WARNING: Using Supabase service role key for server operations. Keep this secret and never expose it to browsers." + ) +BLOG_IMAGES_BUCKET = ( + os.getenv("BLOG_IMAGES_BUCKET") + or os.getenv("SUPERBASE_IMAGES_BUCKET_NAME") + or "blog_images" +) +BLOG_VIDEOS_BUCKET = ( + os.getenv("BLOG_VIDEOS_BUCKET") + or os.getenv("SUPERBASE_VIDEOS_BUCKET_NAME") + or "blog_videos" +) if not SUPABASE_URL or not SUPABASE_KEY: - print("Warning: SUPABASE_URL or SUPERBASE_ANON_KEY is not set. Supabase operations will likely fail.") + print( + "Warning: SUPABASE_URL or SUPERBASE_ANON_KEY is not set. Supabase operations will likely fail." + ) supabase_client = supabase.create_client(SUPABASE_URL, SUPABASE_KEY) -worker = Worker(videos_bucket=BLOG_VIDEOS_BUCKET, SUPABASE_KEY=SUPABASE_KEY, SUPABASE_URL=SUPABASE_URL) +worker = Worker( + videos_bucket=BLOG_VIDEOS_BUCKET, + SUPABASE_KEY=SUPABASE_KEY, + SUPABASE_URL=SUPABASE_URL, +) -UPLOAD_FOLDER = 'static/uploads' -ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'mp4', 'mov', 'avi', 'mkv', 'webm'} -app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER +UPLOAD_FOLDER = "static/uploads" +ALLOWED_EXTENSIONS = { + "png", + "jpg", + "jpeg", + "gif", + "bmp", + "webp", + "mp4", + "mov", + "avi", + "mkv", + "webm", +} +app.config["UPLOAD_FOLDER"] = UPLOAD_FOLDER TIMESTAMP_FIELD = None def resolve_timestamp_field(): global TIMESTAMP_FIELD - candidates = ('timestamp', 'created_at', 'published_at', 'posted_at', 'time', 'date', 'ts', 'createdat') + candidates = ( + "timestamp", + "created_at", + "published_at", + "posted_at", + "time", + "date", + "ts", + "createdat", + ) try: - resp = supabase_client.table('posts').select('*').limit(1).execute() + resp = supabase_client.table("posts").select("*").limit(1).execute() data = resp.data if data and isinstance(data, list) and len(data) > 0: keys = set(data[0].keys()) @@ -69,66 +121,88 @@ def resolve_timestamp_field(): except Exception as e: print(f"resolve_timestamp_field: probing posts failed: {e}") - TIMESTAMP_FIELD = 'timestamp' + TIMESTAMP_FIELD = "timestamp" print("Falling back to timestamp field: 'timestamp'") return TIMESTAMP_FIELD + resolve_timestamp_field() def allowed_file(filename): - return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS + return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS @app.before_request def check_persistent_login(): - if 'admin' in session: + if "admin" in session: return - remember_me_token = request.cookies.get('remember_me') + remember_me_token = request.cookies.get("remember_me") if remember_me_token: hashed_token = hashlib.sha256(remember_me_token.encode()).hexdigest() try: # Query Supabase for the persistent login token - response = supabase_client.table('persistent_logins').select('*').eq('token', hashed_token).execute() - + response = ( + supabase_client.table("persistent_logins") + .select("*") + .eq("token", hashed_token) + .execute() + ) + if response.data and len(response.data) > 0: login_record = response.data[0] - expires_at_str = login_record.get('expires_at') + expires_at_str = login_record.get("expires_at") if expires_at_str: expires_at = datetime.fromisoformat(expires_at_str) current_utc = datetime.now(pytz.utc) if expires_at > current_utc: - session['admin'] = True + session["admin"] = True session.permanent = True else: - response = make_response(redirect(url_for('home'))) - response.delete_cookie('remember_me') - supabase_client.table('persistent_logins').delete().eq('token', hashed_token).execute() + response = make_response(redirect(url_for("home"))) + response.delete_cookie("remember_me") + supabase_client.table("persistent_logins").delete().eq( + "token", hashed_token + ).execute() return response else: - response = make_response(redirect(url_for('home'))) - response.delete_cookie('remember_me') + response = make_response(redirect(url_for("home"))) + response.delete_cookie("remember_me") return response except Exception as e: print(f"Error checking persistent login: {e}") - return redirect(url_for('home')) + return redirect(url_for("home")) -@app.route('/') +@app.route("/") def home(): - admin_logged_in = session.get('admin', False) - + admin_logged_in = session.get("admin", False) + # Select fields dynamically based on the resolved TIMESTAMP_FIELD - - select_fields = f"id, title, content, image, {TIMESTAMP_FIELD}, video_id" if TIMESTAMP_FIELD else "id, title, content, image, video_id" + + select_fields = ( + f"id, title, content, image, {TIMESTAMP_FIELD}, video_id" + if TIMESTAMP_FIELD + else "id, title, content, image, video_id" + ) try: - response = supabase_client.table('posts').select(select_fields).order(TIMESTAMP_FIELD if TIMESTAMP_FIELD else 'id', desc=True).execute() + response = ( + supabase_client.table("posts") + .select(select_fields) + .order(TIMESTAMP_FIELD if TIMESTAMP_FIELD else "id", desc=True) + .execute() + ) posts_data = response.data or [] except Exception as e: print(f"Error fetching posts ordered by {TIMESTAMP_FIELD}: {e}") - response = supabase_client.table('posts').select('id, title, content, image, video_id').order('id', desc=True).execute() + response = ( + supabase_client.table("posts") + .select("id, title, content, image, video_id") + .order("id", desc=True) + .execute() + ) posts_data = response.data or [] posts = [] @@ -141,81 +215,147 @@ def home(): except Exception: formatted_timestamp = str(ts_value) else: - formatted_timestamp = '' - + formatted_timestamp = "" + videodata = None - video_id = post.get('video_id') + video_id = post.get("video_id") if video_id: try: - video_resp = supabase_client.table('videos').select('filepath', 'filename', 'status').eq('id', video_id).single().execute() + video_resp = ( + supabase_client.table("videos") + .select("filepath", "filename", "status") + .eq("id", video_id) + .single() + .execute() + ) video_record = video_resp.data if video_record: videodata = { - 'id': video_id, - 'filename': video_record.get('filename'), - 'filepath': video_record.get('filepath'), - 'status': video_record.get('status'), - 'url': video_record.get('filepath') + "id": video_id, + "filename": video_record.get("filename"), + "filepath": video_record.get("filepath"), + "status": video_record.get("status"), + "url": video_record.get("filepath"), } except Exception as e: print(f"Error fetching video info for video_id={video_id}: {e}") - - posts.append((post.get('id'), post.get('title'), Markup(post.get('content', '')), post.get('image'), formatted_timestamp, videodata)) - + posts.append( + ( + post.get("id"), + post.get("title"), + Markup(post.get("content", "")), + post.get("image"), + formatted_timestamp, + videodata, + ) + ) + available_years = [] available_months = [] available_days = [] try: if TIMESTAMP_FIELD: - years_response = supabase_client.table('posts').select(TIMESTAMP_FIELD).order(TIMESTAMP_FIELD, desc=True).execute() - year_vals = [p.get(TIMESTAMP_FIELD) for p in (years_response.data or []) if p.get(TIMESTAMP_FIELD)] - available_years = sorted(list({parser.parse(v).strftime('%Y') for v in year_vals}), reverse=True) - - months_response = supabase_client.table('posts').select(TIMESTAMP_FIELD).order(TIMESTAMP_FIELD, desc=False).execute() - month_vals = [p.get(TIMESTAMP_FIELD) for p in (months_response.data or []) if p.get(TIMESTAMP_FIELD)] - available_months = sorted(list({parser.parse(v).strftime('%m') for v in month_vals})) - - days_response = supabase_client.table('posts').select(TIMESTAMP_FIELD).order(TIMESTAMP_FIELD, desc=False).execute() - day_vals = [p.get(TIMESTAMP_FIELD) for p in (days_response.data or []) if p.get(TIMESTAMP_FIELD)] - available_days = sorted(list({parser.parse(v).strftime('%d') for v in day_vals})) + years_response = ( + supabase_client.table("posts") + .select(TIMESTAMP_FIELD) + .order(TIMESTAMP_FIELD, desc=True) + .execute() + ) + year_vals = [ + p.get(TIMESTAMP_FIELD) + for p in (years_response.data or []) + if p.get(TIMESTAMP_FIELD) + ] + available_years = sorted( + list({parser.parse(v).strftime("%Y") for v in year_vals}), reverse=True + ) + + months_response = ( + supabase_client.table("posts") + .select(TIMESTAMP_FIELD) + .order(TIMESTAMP_FIELD, desc=False) + .execute() + ) + month_vals = [ + p.get(TIMESTAMP_FIELD) + for p in (months_response.data or []) + if p.get(TIMESTAMP_FIELD) + ] + available_months = sorted( + list({parser.parse(v).strftime("%m") for v in month_vals}) + ) + + days_response = ( + supabase_client.table("posts") + .select(TIMESTAMP_FIELD) + .order(TIMESTAMP_FIELD, desc=False) + .execute() + ) + day_vals = [ + p.get(TIMESTAMP_FIELD) + for p in (days_response.data or []) + if p.get(TIMESTAMP_FIELD) + ] + available_days = sorted( + list({parser.parse(v).strftime("%d") for v in day_vals}) + ) except Exception as e: print(f"Error fetching available years/months/days: {e}") - return render_template('index.html', admin=admin_logged_in, posts=posts, - available_years=available_years, - available_months=available_months, - available_days=available_days, - dark_mode=True) + return render_template( + "index.html", + admin=admin_logged_in, + posts=posts, + available_years=available_years, + available_months=available_months, + available_days=available_days, + dark_mode=True, + ) -@app.route('/filter', methods=['GET']) +@app.route("/filter", methods=["GET"]) def filter_posts(): - year = request.args.get('year', 'any') - month = request.args.get('month', 'any') - day = request.args.get('day', 'any') + year = request.args.get("year", "any") + month = request.args.get("month", "any") + day = request.args.get("day", "any") # Build query using the detected timestamp field. If timestamp isn't available, return all posts. - select_fields = f"id, title, content, image, {TIMESTAMP_FIELD}" if TIMESTAMP_FIELD else "id, title, content, image" + select_fields = ( + f"id, title, content, image, {TIMESTAMP_FIELD}" + if TIMESTAMP_FIELD + else "id, title, content, image" + ) if not TIMESTAMP_FIELD: - response = supabase_client.table('posts').select(select_fields).order('id', desc=True).execute() + response = ( + supabase_client.table("posts") + .select(select_fields) + .order("id", desc=True) + .execute() + ) posts_data = response.data or [] else: - query = supabase_client.table('posts').select(select_fields) - - if year != 'any': - query = query.gte(TIMESTAMP_FIELD, f"{year}-01-01T00:00:00Z").lt(TIMESTAMP_FIELD, f"{int(year) + 1}-01-01T00:00:00Z") - if month != 'any': - current_year = year if year != 'any' else str(datetime.now().year) + query = supabase_client.table("posts").select(select_fields) + + if year != "any": + query = query.gte(TIMESTAMP_FIELD, f"{year}-01-01T00:00:00Z").lt( + TIMESTAMP_FIELD, f"{int(year) + 1}-01-01T00:00:00Z" + ) + if month != "any": + current_year = year if year != "any" else str(datetime.now().year) next_month = int(month) + 1 next_year = int(current_year) if next_month > 12: next_month = 1 next_year += 1 - query = query.gte(TIMESTAMP_FIELD, f"{current_year}-{month.zfill(2)}-01T00:00:00Z").lt(TIMESTAMP_FIELD, f"{next_year}-{str(next_month).zfill(2)}-01T00:00:00Z") - if day != 'any': - current_year = year if year != 'any' else str(datetime.now().year) - current_month = month if month != 'any' else str(datetime.now().month) + query = query.gte( + TIMESTAMP_FIELD, f"{current_year}-{month.zfill(2)}-01T00:00:00Z" + ).lt( + TIMESTAMP_FIELD, f"{next_year}-{str(next_month).zfill(2)}-01T00:00:00Z" + ) + if day != "any": + current_year = year if year != "any" else str(datetime.now().year) + current_month = month if month != "any" else str(datetime.now().month) next_day = int(day) + 1 next_month_for_day = int(current_month) next_year_for_day = int(current_year) @@ -229,15 +369,25 @@ def filter_posts(): next_month_for_day = 1 next_year_for_day += 1 - query = query.gte(TIMESTAMP_FIELD, f"{current_year}-{current_month.zfill(2)}-{day.zfill(2)}T00:00:00Z").lt(TIMESTAMP_FIELD, f"{current_year}-{current_month.zfill(2)}-{str(next_day).zfill(2)}T00:00:00Z") - + query = query.gte( + TIMESTAMP_FIELD, + f"{current_year}-{current_month.zfill(2)}-{day.zfill(2)}T00:00:00Z", + ).lt( + TIMESTAMP_FIELD, + f"{current_year}-{current_month.zfill(2)}-{str(next_day).zfill(2)}T00:00:00Z", + ) try: response = query.order(TIMESTAMP_FIELD, desc=True).execute() posts_data = response.data or [] except Exception as e: print(f"Error executing filtered query on {TIMESTAMP_FIELD}: {e}") - response = supabase_client.table('posts').select('id, title, content, image').order('id', desc=True).execute() + response = ( + supabase_client.table("posts") + .select("id, title, content, image") + .order("id", desc=True) + .execute() + ) posts_data = response.data or [] posts = [] @@ -245,7 +395,9 @@ def filter_posts(): ts_value = post.get(TIMESTAMP_FIELD) if TIMESTAMP_FIELD else None if ts_value: try: - dt_object = datetime.strptime(ts_value, "%Y-%m-%dT%H:%M:%S%z").replace(tzinfo=None) + dt_object = datetime.strptime(ts_value, "%Y-%m-%dT%H:%M:%S%z").replace( + tzinfo=None + ) except Exception: try: dt_object = datetime.strptime(ts_value, "%Y-%m-%d %H:%M:%S") @@ -257,96 +409,155 @@ def filter_posts(): else: dt_object = None - formatted_ts = dt_object.strftime("%Y-%m-%d %I:%M %p") if dt_object else '' - posts.append((post.get('id'), post.get('title'), Markup(post.get('content', '')), post.get('image'), formatted_ts)) + formatted_ts = dt_object.strftime("%Y-%m-%d %I:%M %p") if dt_object else "" + posts.append( + ( + post.get("id"), + post.get("title"), + Markup(post.get("content", "")), + post.get("image"), + formatted_ts, + ) + ) available_years = [] available_months = [] available_days = [] try: if TIMESTAMP_FIELD: - yrs_resp = supabase_client.table('posts').select(TIMESTAMP_FIELD).order(TIMESTAMP_FIELD, desc=True).execute() - available_years = sorted(list({parser.parse(p.get(TIMESTAMP_FIELD)).strftime('%Y') for p in (yrs_resp.data or []) if p.get(TIMESTAMP_FIELD)}), reverse=True) - - mos_resp = supabase_client.table('posts').select(TIMESTAMP_FIELD).order(TIMESTAMP_FIELD, desc=False).execute() - available_months = sorted(list({parser.parse(p.get(TIMESTAMP_FIELD)).strftime('%m') for p in (mos_resp.data or []) if p.get(TIMESTAMP_FIELD)})) - - d_resp = supabase_client.table('posts').select(TIMESTAMP_FIELD).order(TIMESTAMP_FIELD, desc=False).execute() - available_days = sorted(list({parser.parse(p.get(TIMESTAMP_FIELD)).strftime('%d') for p in (d_resp.data or []) if p.get(TIMESTAMP_FIELD)})) + yrs_resp = ( + supabase_client.table("posts") + .select(TIMESTAMP_FIELD) + .order(TIMESTAMP_FIELD, desc=True) + .execute() + ) + available_years = sorted( + list( + { + parser.parse(p.get(TIMESTAMP_FIELD)).strftime("%Y") + for p in (yrs_resp.data or []) + if p.get(TIMESTAMP_FIELD) + } + ), + reverse=True, + ) + + mos_resp = ( + supabase_client.table("posts") + .select(TIMESTAMP_FIELD) + .order(TIMESTAMP_FIELD, desc=False) + .execute() + ) + available_months = sorted( + list( + { + parser.parse(p.get(TIMESTAMP_FIELD)).strftime("%m") + for p in (mos_resp.data or []) + if p.get(TIMESTAMP_FIELD) + } + ) + ) + + d_resp = ( + supabase_client.table("posts") + .select(TIMESTAMP_FIELD) + .order(TIMESTAMP_FIELD, desc=False) + .execute() + ) + available_days = sorted( + list( + { + parser.parse(p.get(TIMESTAMP_FIELD)).strftime("%d") + for p in (d_resp.data or []) + if p.get(TIMESTAMP_FIELD) + } + ) + ) except Exception as e: print(f"Error building available_years/months/days (filter route): {e}") - return render_template('index.html', posts=posts, - available_years=available_years, - available_months=available_months, - available_days=available_days, - dark_mode=True, admin=('admin' in session)) + return render_template( + "index.html", + posts=posts, + available_years=available_years, + available_months=available_months, + available_days=available_days, + dark_mode=True, + admin=("admin" in session), + ) + -@app.route('/login', methods=['GET', 'POST']) +@app.route("/login", methods=["GET", "POST"]) def login(): - ADMIN_USERNAME = os.environ.get('ADMIN_USERNAME') - ADMIN_PASSWORD = os.environ.get('ADMIN_PASSWORD') - if request.method == 'POST': - username = request.form['username'] - password = request.form['password'] + ADMIN_USERNAME = os.environ.get("ADMIN_USERNAME") + ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD") + if request.method == "POST": + username = request.form["username"] + password = request.form["password"] if username == ADMIN_USERNAME and password == ADMIN_PASSWORD: - remember = request.form.get('remember') - session['admin'] = True - response = make_response(redirect(url_for('home'))) + remember = request.form.get("remember") + session["admin"] = True + response = make_response(redirect(url_for("home"))) if remember: token = str(uuid.uuid4()) hashed_token = hashlib.sha256(token.encode()).hexdigest() - + expires_at = datetime.now(pytz.utc) + timedelta(days=30) try: - supabase_client.table('persistent_logins').insert({ - 'user_id': 'admin', - 'token': hashed_token, - 'expires_at': expires_at.isoformat() - }).execute() - response.set_cookie('remember_me', token, max_age=30 * 24 * 60 * 60) + supabase_client.table("persistent_logins").insert( + { + "user_id": "admin", + "token": hashed_token, + "expires_at": expires_at.isoformat(), + } + ).execute() + response.set_cookie("remember_me", token, max_age=30 * 24 * 60 * 60) except Exception as e: print(f"Error setting persistent login: {e}") return response return response else: - return render_template('login.html', error='Invalid credentials') - - return render_template('login.html') + return render_template("login.html", error="Invalid credentials") + + return render_template("login.html") -@app.route('/logout') + +@app.route("/logout") def logout(): - session.pop('admin', None) + session.pop("admin", None) # Delete persistent login token from DB and cookie - remember_me_token = request.cookies.get('remember_me') + remember_me_token = request.cookies.get("remember_me") if remember_me_token: hashed_token = hashlib.sha256(remember_me_token.encode()).hexdigest() try: - supabase_client.table('persistent_logins').delete().eq('token', hashed_token).execute() + supabase_client.table("persistent_logins").delete().eq( + "token", hashed_token + ).execute() except Exception as e: print(f"Error deleting persistent login token from DB: {e}") - response = make_response(redirect(url_for('home'))) - response.delete_cookie('remember_me') + response = make_response(redirect(url_for("home"))) + response.delete_cookie("remember_me") return response -@app.route('/new', methods=['GET', 'POST']) + +@app.route("/new", methods=["GET", "POST"]) def new_post(): - if 'admin' not in session: - return redirect(url_for('login')) + if "admin" not in session: + return redirect(url_for("login")) - if request.method == 'POST': - title = request.form['title'] - content = request.form['content'] + if request.method == "POST": + title = request.form["title"] + content = request.form["content"] image_url = None video_id = None - if 'image' in request.files: - file = request.files['image'] + if "image" in request.files: + file = request.files["image"] if file and allowed_file(file.filename): filename = secure_filename(file.filename) # Read file bytes into memory first to avoid stream/closed-file issues @@ -358,41 +569,48 @@ def new_post(): if file_bytes: try: - response = supabase_client.storage.from_(BLOG_IMAGES_BUCKET).upload(filename, file_bytes, {"content-type": file.content_type}) + supabase_client.storage.from_(BLOG_IMAGES_BUCKET).upload( + filename, file_bytes, {"content-type": file.content_type} + ) image_url = f"{SUPABASE_URL}/storage/v1/object/public/{BLOG_IMAGES_BUCKET}/{filename}" except Exception as e: - print(f"Error uploading image to Superbase: {e}") + print(f"Error uploading image to Superbase: {e}") try: if int(e.status) == 409: image_url = f"{SUPABASE_URL}/storage/v1/object/public/{BLOG_IMAGES_BUCKET}/{filename}" else: - os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True) - local_path = os.path.join(app.config['UPLOAD_FOLDER'], filename) - with open(local_path, 'wb') as out_f: + os.makedirs(app.config["UPLOAD_FOLDER"], exist_ok=True) + local_path = os.path.join( + app.config["UPLOAD_FOLDER"], filename + ) + with open(local_path, "wb") as out_f: out_f.write(file_bytes) image_url = f"/static/uploads/{filename}" - print(f"Saved image locally to {local_path}; using {image_url} as image URL") + print( + f"Saved image locally to {local_path}; using {image_url} as image URL" + ) except Exception as e2: print(f"Failed to save image locally: {e2}") image_url = None - - if 'video' in request.files: - file = request.files['video'] + + if "video" in request.files: + file = request.files["video"] if file and allowed_file(file.filename): filename = secure_filename(file.filename) try: worker_response = worker.save_file(file) - video_id = worker_response.get('file_id') + video_id = worker_response.get("file_id") if video_id: worker.queue_file(video_id) - print(f"Queued video file for processing with video_id={video_id}") + print( + f"Queued video file for processing with video_id={video_id}" + ) else: video_id = None print("Failed to get video_id after saving video file") except Exception as e: print(f"Error saving or queuing video file: {e}") - timestamp = get_local_timestamp() try: @@ -401,19 +619,40 @@ def new_post(): inserted = False while attempt < max_attempts and not inserted: try: - max_resp = supabase_client.table('posts').select('id').order('id', desc=True).limit(1).execute() + max_resp = ( + supabase_client.table("posts") + .select("id") + .order("id", desc=True) + .limit(1) + .execute() + ) max_rows = max_resp.data or [] - current_max = int(max_rows[0].get('id')) if max_rows and max_rows[0].get('id') is not None else 0 + current_max = ( + int(max_rows[0].get("id")) + if max_rows and max_rows[0].get("id") is not None + else 0 + ) new_id = current_max + 1 - supabase_client.table('posts').insert({'id': new_id, 'title': title, 'content': content, 'image': image_url, 'timestamp': timestamp, 'video_id': video_id}).execute() + supabase_client.table("posts").insert( + { + "id": new_id, + "title": title, + "content": content, + "image": image_url, + "timestamp": timestamp, + "video_id": video_id, + } + ).execute() print(f"Inserted post with id={new_id}") inserted = True except Exception as e: err_s = str(e) - if '23505' in err_s or 'duplicate key' in err_s.lower(): + if "23505" in err_s or "duplicate key" in err_s.lower(): attempt += 1 - print(f"Duplicate id conflict on insert (attempt {attempt}), retrying...") + print( + f"Duplicate id conflict on insert (attempt {attempt}), retrying..." + ) continue else: print(f"Error inserting post into Superbase: {e}") @@ -421,47 +660,50 @@ def new_post(): if not inserted: print("Failed to insert post after retries; skipping.") else: - flash('Post created successfully!', 'success') + flash("Post created successfully!", "success") except Exception as e: err_s = str(e) - if '23505' in err_s or 'duplicate key' in err_s.lower(): - print(f"Duplicate key conflict when inserting post; skipping insert. Details: {e}") + if "23505" in err_s or "duplicate key" in err_s.lower(): + print( + f"Duplicate key conflict when inserting post; skipping insert. Details: {e}" + ) else: print(f"Error inserting post into Superbase: {e}") - return redirect(url_for('new_post')) + return redirect(url_for("new_post")) + + return render_template("new.html", dark_mode=True) - return render_template('new.html', dark_mode=True) -@app.route('/delete/') +@app.route("/delete/") def delete_post(post_id): - if 'admin' not in session: - return redirect(url_for('login')) + if "admin" not in session: + return redirect(url_for("login")) try: - supabase_client.table('posts').delete().eq('id', post_id).execute() - flash(f'Post: {post_id} deleted successfully!', 'success') + supabase_client.table("posts").delete().eq("id", post_id).execute() + flash(f"Post: {post_id} deleted successfully!", "success") except Exception as e: - flash(f'Error deleting post: {post_id}!', 'error') + flash(f"Error deleting post: {post_id}!", "error") print(f"Error deleting post from Superbase: {e}") - return redirect(url_for('home')) + return redirect(url_for("home")) -@app.route('/edit/', methods=['GET', 'POST']) +@app.route("/edit/", methods=["GET", "POST"]) def edit_post(post_id): - if 'admin' not in session: - return redirect(url_for('login')) + if "admin" not in session: + return redirect(url_for("login")) - if request.method == 'POST': - title = request.form['title'] - content = request.form['content'] - current_image_url = request.form.get('current_image_url') + if request.method == "POST": + title = request.form["title"] + content = request.form["content"] + current_image_url = request.form.get("current_image_url") image_url = current_image_url - video_id = request.form.get('current_video_id') + video_id = request.form.get("current_video_id") - if 'image' in request.files: - file = request.files['image'] + if "image" in request.files: + file = request.files["image"] if file and allowed_file(file.filename): filename = secure_filename(file.filename) try: @@ -472,27 +714,33 @@ def edit_post(post_id): if file_bytes: try: - supabase_client.storage.from_(BLOG_IMAGES_BUCKET).upload(filename, file_bytes, {"content-type": file.content_type}) + supabase_client.storage.from_(BLOG_IMAGES_BUCKET).upload( + filename, file_bytes, {"content-type": file.content_type} + ) image_url = f"{SUPABASE_URL}/storage/v1/object/public/{BLOG_IMAGES_BUCKET}/{filename}" except Exception as e: print(f"Error uploading new image to Supabase: {e}") - if hasattr(e, 'statusCode') and e.statusCode == 409: + if hasattr(e, "statusCode") and e.statusCode == 409: image_url = f"{SUPABASE_URL}/storage/v1/object/public/{BLOG_IMAGES_BUCKET}/{filename}" else: - os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True) - local_path = os.path.join(app.config['UPLOAD_FOLDER'], filename) - with open(local_path, 'wb') as out_f: + os.makedirs(app.config["UPLOAD_FOLDER"], exist_ok=True) + local_path = os.path.join( + app.config["UPLOAD_FOLDER"], filename + ) + with open(local_path, "wb") as out_f: out_f.write(file_bytes) image_url = f"/static/uploads/{filename}" - print(f"Saved image locally to {local_path}; using {image_url} as image URL") + print( + f"Saved image locally to {local_path}; using {image_url} as image URL" + ) - if 'video' in request.files: - file = request.files['video'] + if "video" in request.files: + file = request.files["video"] if file and allowed_file(file.filename): filename = secure_filename(file.filename) try: worker_response = worker.save_file(file) - video_id = worker_response.get('file_id') + video_id = worker_response.get("file_id") if video_id: worker.queue_file(video_id) else: @@ -500,67 +748,111 @@ def edit_post(post_id): except Exception as e: print(f"Error saving or queuing video file: {e}") try: - supabase_client.table('posts').update({'title': title, 'content': content, 'image': image_url, 'video_id': video_id}).eq('id', post_id).execute() - flash('Post updated successfully!', 'success') - return redirect(url_for('home')) + supabase_client.table("posts").update( + { + "title": title, + "content": content, + "image": image_url, + "video_id": video_id, + } + ).eq("id", post_id).execute() + flash("Post updated successfully!", "success") + return redirect(url_for("home")) except Exception as e: print(f"Error updating post in Supabase: {e}") - flash('Failed to update post.', 'error') - return render_template('edit.html', post=request.form, error="Failed to update post.", dark_mode=True) + flash("Failed to update post.", "error") + return render_template( + "edit.html", + post=request.form, + error="Failed to update post.", + dark_mode=True, + ) try: - select_fields = f"id, title, content, image, {TIMESTAMP_FIELD}, video_id" if TIMESTAMP_FIELD else 'id, title, content, image, video_id' - response = supabase_client.table('posts').select(select_fields).eq('id', post_id).single().execute() + select_fields = ( + f"id, title, content, image, {TIMESTAMP_FIELD}, video_id" + if TIMESTAMP_FIELD + else "id, title, content, image, video_id" + ) + response = ( + supabase_client.table("posts") + .select(select_fields) + .eq("id", post_id) + .single() + .execute() + ) post = response.data if post: - if post['video_id']: - video_id = post['video_id'] + if post["video_id"]: + video_id = post["video_id"] try: - video_resp = supabase_client.table('videos').select('filepath', 'filename', 'status').eq('id', video_id).single().execute() + video_resp = ( + supabase_client.table("videos") + .select("filepath", "filename", "status") + .eq("id", video_id) + .single() + .execute() + ) video_record = video_resp.data if video_record: videodata = { - 'id': video_id, - 'filename': video_record.get('filename'), - 'filepath': video_record.get('filepath'), - 'status': video_record.get('status'), - 'url': video_record.get('filepath') + "id": video_id, + "filename": video_record.get("filename"), + "filepath": video_record.get("filepath"), + "status": video_record.get("status"), + "url": video_record.get("filepath"), } - post['video'] = videodata - except: - print(f"Error fetching video info for video_id={video_id}") + post["video"] = videodata + except Exception as e: + print(f"Error fetching video info for video_id={video_id}: {e}") - return render_template('edit.html', post=post, dark_mode=True) + return render_template("edit.html", post=post, dark_mode=True) else: - flash('Post not found.', 'error') - return redirect(url_for('home')) - except Exception as e: - flash(f'Post not found.', 'error') - return redirect(url_for('home')) + flash("Post not found.", "error") + return redirect(url_for("home")) + except Exception: + flash("Post not found.", "error") + return redirect(url_for("home")) + -@app.route('/check_session') +@app.route("/check_session") def check_session(): - return jsonify({"admin": session.get("admin", True)}) + return jsonify({"admin": bool(session.get("admin", False))}) -@app.route('/set_session', methods=['POST']) + +@app.route("/set_session", methods=["POST"]) def set_session(): + if not session.get("admin"): + return jsonify({"error": "Forbidden"}), 403 + data = request.json - if data.get("admin") == True: + if data.get("admin"): session["admin"] = True return jsonify({"message": "Session updated", "admin": True}) else: session.pop("admin", None) return jsonify({"message": "Session removed", "admin": False}) -@app.route('/post/') + +@app.route("/post/") def view_post(post_id): - select_fields = f"id, title, content, image, {TIMESTAMP_FIELD}, video_id" if TIMESTAMP_FIELD else 'id, title, content, image, video_id' - response = supabase_client.table('posts').select(select_fields).eq('id', post_id).single().execute() + select_fields = ( + f"id, title, content, image, {TIMESTAMP_FIELD}, video_id" + if TIMESTAMP_FIELD + else "id, title, content, image, video_id" + ) + response = ( + supabase_client.table("posts") + .select(select_fields) + .eq("id", post_id) + .single() + .execute() + ) post = response.data if post: - ts_val = post.get(TIMESTAMP_FIELD) if TIMESTAMP_FIELD else post.get('timestamp') - formatted_timestamp = '' + ts_val = post.get(TIMESTAMP_FIELD) if TIMESTAMP_FIELD else post.get("timestamp") + formatted_timestamp = "" if ts_val: try: dt_object = parser.parse(ts_val).replace(tzinfo=None) @@ -568,89 +860,108 @@ def view_post(post_id): except Exception: formatted_timestamp = str(ts_val) - image_url = post.get('image') + image_url = post.get("image") videodata = None - video_id = post.get('video_id') + video_id = post.get("video_id") if video_id: try: - video_resp = supabase_client.table('videos').select('filepath', 'filename', 'status').eq('id', video_id).single().execute() + video_resp = ( + supabase_client.table("videos") + .select("filepath", "filename", "status") + .eq("id", video_id) + .single() + .execute() + ) video_record = video_resp.data if video_record: videodata = { - 'id': video_id, - 'filename': video_record.get('filename'), - 'filepath': video_record.get('filepath'), - 'status': video_record.get('status'), - 'url': video_record.get('filepath') + "id": video_id, + "filename": video_record.get("filename"), + "filepath": video_record.get("filepath"), + "status": video_record.get("status"), + "url": video_record.get("filepath"), } except Exception as e: print(f"Error fetching video info for video_id={video_id}: {e}") - return render_template('post.html', post={ - "id": post.get('id'), - "title": post.get('title'), - "content": Markup(post.get('content', '')), - "image": image_url, - "timestamp": formatted_timestamp, - "video": videodata - }, dark_mode=True) + return render_template( + "post.html", + post={ + "id": post.get("id"), + "title": post.get("title"), + "content": Markup(post.get("content", "")), + "image": image_url, + "timestamp": formatted_timestamp, + "video": videodata, + }, + dark_mode=True, + ) else: return "Post not found", 404 -@app.route('/admin/inspect') +@app.route("/admin/inspect") def admin_inspect(): - if 'admin' not in session: - return jsonify({'error': 'admin only'}), 403 + if "admin" not in session: + return jsonify({"error": "admin only"}), 403 # Mask the key for safety in logs/UI masked_key = None if SUPABASE_KEY: sk = SUPABASE_KEY - masked_key = sk[:4] + '...' + sk[-4:] if len(sk) > 8 else '***' + masked_key = sk[:4] + "..." + sk[-4:] if len(sk) > 8 else "***" info = { - 'supabase_url': SUPABASE_URL, - 'supabase_key_masked': masked_key, - 'images_bucket': BLOG_IMAGES_BUCKET, + "supabase_url": SUPABASE_URL, + "supabase_key_masked": masked_key, + "images_bucket": BLOG_IMAGES_BUCKET, } try: - resp = supabase_client.table('posts').select('id').order('id', desc=True).limit(1).execute() + resp = ( + supabase_client.table("posts") + .select("id") + .order("id", desc=True) + .limit(1) + .execute() + ) max_id = None if resp and resp.data and len(resp.data) > 0: - max_id = resp.data[0].get('id') - info['max_id_seen_by_app'] = max_id + max_id = resp.data[0].get("id") + info["max_id_seen_by_app"] = max_id except Exception as e: - info['max_id_error'] = str(e) + info["max_id_error"] = str(e) try: - resp2 = supabase_client.table('posts').select('id').execute() - info['posts_count_seen_by_app'] = len(resp2.data or []) + resp2 = supabase_client.table("posts").select("id").execute() + info["posts_count_seen_by_app"] = len(resp2.data or []) except Exception as e: - info['posts_count_error'] = str(e) + info["posts_count_error"] = str(e) return jsonify(info) -@app.route('/uploads/') +@app.route("/uploads/") def uploaded_file(filename): - UPLOAD_FOLDER = 'tmp/uploads' + UPLOAD_FOLDER = "tmp/uploads" os.makedirs(UPLOAD_FOLDER, exist_ok=True) return send_from_directory(UPLOAD_FOLDER, filename) -if __name__ == '__main__': + +if __name__ == "__main__": """ Main entry point for running the Flask application. """ try: - port = int(os.getenv('PORT', 8080)) - host = os.getenv('HOST', '127.0.0.1') + port = int(os.getenv("PORT", 8080)) + host = os.getenv("HOST", "127.0.0.1") print(f"Starting Flask app on {host}:{port} (debug={app.debug})") app.run(host=host, port=port, threaded=True) except Exception as e: - import traceback, sys + import sys + import traceback + traceback.print_exc() print(f"Failed to start Flask app: {e}") # Exit with non-zero so container/monitoring sees failure diff --git a/pyproject.toml b/pyproject.toml index bfa7dbb..649a918 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,4 +21,9 @@ dependencies = [ dev = [ "playwright>=1.55.0", "pytest>=8.4.2", + "ruff>=0.14.5", ] + +[tool.ruff.format] +quote-style = "double" +docstring-code-format = true \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index 6465fc0..9d6aa4f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,34 +1,49 @@ -import pytest -from playwright.sync_api import Page, expect, sync_playwright import os +import signal import subprocess import time + +import pytest import requests from dotenv import load_dotenv +from playwright.sync_api import Page, expect, sync_playwright load_dotenv() BASE_URL = "http://127.0.0.1:8080" + # Fixture to start and stop the Flask application @pytest.fixture(scope="session") def flask_app_url(): # Set environment variables for admin credentials env = os.environ.copy() - env["ADMIN_USERNAME"] = os.getenv('ADMIN_USERNAME') - env["ADMIN_PASSWORD"] = os.getenv('ADMIN_PASSWORD') - env["SUPABASE_URL"] = os.getenv('SUPABASE_URL') + env["ADMIN_USERNAME"] = os.getenv("ADMIN_USERNAME") + env["ADMIN_PASSWORD"] = os.getenv("ADMIN_PASSWORD") + env["SUPABASE_URL"] = os.getenv("SUPABASE_URL") env["SUPABASE_KEY"] = ( - os.getenv('SUPABASE_SERVICE_ROLE_KEY') or - os.getenv('SUPERBASE_SERVICE_ROLE_KEY') or - os.getenv('SUPERBASE_ANON_KEY') or - os.getenv('SUPABASE_ANON_KEY') -) - if os.name == 'nt': - process = subprocess.Popen(["start", "python", "app.py"], shell=True, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + os.getenv("SUPABASE_SERVICE_ROLE_KEY") + or os.getenv("SUPERBASE_SERVICE_ROLE_KEY") + or os.getenv("SUPERBASE_ANON_KEY") + or os.getenv("SUPABASE_ANON_KEY") + ) + if os.name == "nt": + process = subprocess.Popen( + ["start", "python", "app.py"], + shell=True, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) else: - process = subprocess.Popen(["python", "app.py"], env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, preexec_fn=os.setsid) + process = subprocess.Popen( + ["python", "app.py"], + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + preexec_fn=os.setsid, + ) for _ in range(20): try: @@ -44,12 +59,15 @@ def flask_app_url(): yield BASE_URL - if os.name == 'nt': - subprocess.run(["taskkill", "/F", "/PID", str(process.pid)], capture_output=True) + if os.name == "nt": + subprocess.run( + ["taskkill", "/F", "/PID", str(process.pid)], capture_output=True + ) else: - os.killpg(os.getpgid(process.pid), subprocess.signal.SIGTERM) + os.killpg(os.getpgid(process.pid), signal.SIGTERM) print("Flask app stopped.") + @pytest.fixture(scope="function") def page(flask_app_url): with sync_playwright() as p: @@ -58,10 +76,11 @@ def page(flask_app_url): yield page browser.close() + # Fixture for admin login @pytest.fixture(scope="function") def admin_logged_in_page(page: Page, flask_app_url): - page.goto('about:blank', timeout=600000) + page.goto("about:blank", timeout=600000) page.goto(f"{flask_app_url}/login", timeout=600000) page.fill("input[name='username']", os.environ["ADMIN_USERNAME"]) page.fill("input[name='password']", os.environ["ADMIN_PASSWORD"]) diff --git a/tests/test_blog.py b/tests/test_blog.py index 8423fa3..8ed9f47 100644 --- a/tests/test_blog.py +++ b/tests/test_blog.py @@ -1,18 +1,20 @@ -import pytest -from playwright.sync_api import Page, expect import os -import time import re +import time + from dotenv import load_dotenv +from playwright.sync_api import Page, expect load_dotenv() + + def test_admin_login_logout(page: Page, flask_app_url): page.goto(f"{flask_app_url}/login", timeout=600000) expect(page).to_have_title("Login - Blog", timeout=600000) # Fill in correct credentials - page.fill("input[name='username']", os.getenv('ADMIN_USERNAME')) - page.fill("input[name='password']", os.getenv('ADMIN_PASSWORD')) + page.fill("input[name='username']", os.getenv("ADMIN_USERNAME")) + page.fill("input[name='password']", os.getenv("ADMIN_PASSWORD")) page.click("button[type='submit']") expect(page).to_have_url(f"{flask_app_url}/", timeout=600000) @@ -24,6 +26,7 @@ def test_admin_login_logout(page: Page, flask_app_url): expect(page.locator("a", has_text="Login")).to_be_visible(timeout=600000) expect(page.locator("a", has_text="New Post")).not_to_be_visible(timeout=600000) + def test_admin_login_incorrect_credentials(page: Page, flask_app_url): page.goto(f"{flask_app_url}/login", timeout=600000) expect(page).to_have_title("Login - Blog", timeout=600000) @@ -33,23 +36,29 @@ def test_admin_login_incorrect_credentials(page: Page, flask_app_url): page.fill("input[name='password']", "wrongpass") page.click("button[type='submit']") - expect(page.locator(".error-message")).to_have_text("Invalid credentials", timeout=600000) + expect(page.locator(".error-message")).to_have_text( + "Invalid credentials", timeout=600000 + ) expect(page).to_have_url(f"{flask_app_url}/login", timeout=600000) + def test_new_post_authorization(page: Page, flask_app_url): page.goto(f"{flask_app_url}/new", timeout=600000) expect(page).to_have_url(f"{flask_app_url}/login", timeout=600000) + def test_delete_post_authorization(page: Page, flask_app_url): # Attempt to access delete endpoint directly page.goto(f"{flask_app_url}/delete/1", timeout=600000) expect(page).to_have_url(f"{flask_app_url}/login", timeout=600000) + def test_admin_inspect_authorization(page: Page, flask_app_url): response = page.goto(f"{flask_app_url}/admin/inspect", timeout=600000) - expect(page.locator("body")).to_have_text("{\"error\":\"admin only\"}", timeout=600000) + expect(page.locator("body")).to_have_text('{"error":"admin only"}', timeout=600000) assert response.status == 403 + def test_create_and_view_post(admin_logged_in_page: Page, flask_app_url): page = admin_logged_in_page page.goto(f"{flask_app_url}/new", timeout=600000) @@ -68,12 +77,13 @@ def test_create_and_view_post(admin_logged_in_page: Page, flask_app_url): expect(page.locator("h1", has_text=test_title)).to_be_visible(timeout=600000) page.locator("a", has_text=test_title).click() - post_id = page.url.split('/')[-1] + post_id = page.url.split("/")[-1] expect(page.locator("h1")).to_have_text(test_title, timeout=600000) expect(page.locator("p").nth(1)).to_have_text(test_content, timeout=600000) page.goto(f"{flask_app_url}/delete/{post_id}", timeout=600000) + def test_create_post_with_image(admin_logged_in_page: Page, flask_app_url): page = admin_logged_in_page page.goto(f"{flask_app_url}/new", timeout=600000) @@ -84,7 +94,9 @@ def test_create_post_with_image(admin_logged_in_page: Page, flask_app_url): image_path = os.path.join(os.path.dirname(__file__), "test_image.png") with open(image_path, "wb") as f: - f.write(b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\x0cIDATx\xda\xed\xc1\x01\x01\x00\x00\x00\xc2\xa0\xf7Om\x00\x00\x00\x00IEND\xaeB`\x82") + f.write( + b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\x0cIDATx\xda\xed\xc1\x01\x01\x00\x00\x00\xc2\xa0\xf7Om\x00\x00\x00\x00IEND\xaeB`\x82" + ) page.fill("input[name='title']", test_title) page.fill("textarea[name='content']", test_content) @@ -97,12 +109,13 @@ def test_create_post_with_image(admin_logged_in_page: Page, flask_app_url): expect(page.locator("h1", has_text=test_title)).to_be_visible(timeout=600000) page.locator("a", has_text=test_title).click() - post_id = page.url.split('/')[-1] + post_id = page.url.split("/")[-1] expect(page.locator(".image")).to_be_visible(timeout=600000) page.goto(f"{flask_app_url}/delete/{post_id}", timeout=600000) os.remove(image_path) + def test_delete_post(admin_logged_in_page: Page, flask_app_url): page = admin_logged_in_page page.goto(f"{flask_app_url}/new", timeout=600000) @@ -118,11 +131,12 @@ def test_delete_post(admin_logged_in_page: Page, flask_app_url): page.goto(f"{flask_app_url}/", timeout=600000) expect(page.locator("h1", has_text=test_title)).to_be_visible(timeout=600000) - post_locator = page.locator(f".post:has(h1:has-text(\"{test_title}\"))") + post_locator = page.locator(f'.post:has(h1:has-text("{test_title}"))') post_locator.locator("a[href^='/delete/']").click() expect(page).to_have_url(f"{flask_app_url}/", timeout=600000) expect(page.locator("h1", has_text=test_title)).not_to_be_visible(timeout=600000) + def test_remember_me_login_persistence(page: Page, flask_app_url): # Login with "remember me" page.goto(f"{flask_app_url}/login", timeout=600000) @@ -137,6 +151,7 @@ def test_remember_me_login_persistence(page: Page, flask_app_url): expect(page.locator("a", has_text="New Post")).to_be_visible(timeout=600000) expect(page.locator("a", has_text="Logout")).to_be_visible(timeout=600000) + # Test for filtering posts (requires posts with different timestamps) # This test will be more effective if the database is pre-populated with diverse posts # For now, we'll just test the UI interaction. @@ -153,9 +168,12 @@ def test_filter_posts_ui(admin_logged_in_page: Page, flask_app_url): page.select_option("#month", "any") page.select_option("#day", "any") page.click("button[type='submit']") - expect(page).to_have_url(re.compile(f"{flask_app_url}/filter.*year=any.*month=any.*day=any"), timeout=600000) + expect(page).to_have_url( + re.compile(f"{flask_app_url}/filter.*year=any.*month=any.*day=any"), + timeout=600000, + ) page.locator(".filter-reset-btn").wait_for(state="visible", timeout=10000) page.evaluate("document.querySelector('.filter-reset-btn').click()") - page.wait_for_load_state('networkidle') + page.wait_for_load_state("networkidle") expect(page).to_have_url(f"{flask_app_url}/", timeout=600000) diff --git a/tests/test_edit_post.py b/tests/test_edit_post.py index 57a23d8..36d6bcf 100644 --- a/tests/test_edit_post.py +++ b/tests/test_edit_post.py @@ -1,16 +1,17 @@ -import pytest -from playwright.sync_api import Page, expect import os import time -import re + +from playwright.sync_api import Page, expect + def test_edit_post_authorization(page: Page, flask_app_url): page.goto(f"{flask_app_url}/edit/1", timeout=600000) expect(page).to_have_url(f"{flask_app_url}/login", timeout=600000) + def test_edit_post_form_loads_with_data(admin_logged_in_page: Page, flask_app_url): page = admin_logged_in_page - + page.goto(f"{flask_app_url}/new", timeout=600000) test_title = f"Post for Editing {time.time()}" test_content = "Original content for the post." @@ -21,7 +22,7 @@ def test_edit_post_form_loads_with_data(admin_logged_in_page: Page, flask_app_ur page.goto(f"{flask_app_url}/", timeout=600000) page.locator("a", has_text=test_title).click() - post_id = page.url.split('/')[-1] + post_id = page.url.split("/")[-1] page.goto(f"{flask_app_url}/edit/{post_id}", timeout=600000) expect(page).to_have_title("Edit Post") @@ -32,6 +33,7 @@ def test_edit_post_form_loads_with_data(admin_logged_in_page: Page, flask_app_ur expect(page.locator("img[alt='Current Post Image']")).not_to_be_visible() page.goto(f"{flask_app_url}/delete/{post_id}", timeout=600000) + def test_edit_post_successful_update(admin_logged_in_page: Page, flask_app_url): page = admin_logged_in_page @@ -45,7 +47,7 @@ def test_edit_post_successful_update(admin_logged_in_page: Page, flask_app_url): page.goto(f"{flask_app_url}/", timeout=600000) page.locator("a", has_text=original_title).click() - post_id = page.url.split('/')[-1] + post_id = page.url.split("/")[-1] page.goto(f"{flask_app_url}/edit/{post_id}", timeout=600000) expect(page).to_have_title("Edit Post") @@ -60,6 +62,8 @@ def test_edit_post_successful_update(admin_logged_in_page: Page, flask_app_url): expect(page.locator("h1", has_text=updated_title)).to_be_visible() page.goto(f"{flask_app_url}/delete/{post_id}", timeout=600000) + + def test_edit_post_update_with_new_image(admin_logged_in_page: Page, flask_app_url): page = admin_logged_in_page @@ -73,14 +77,16 @@ def test_edit_post_update_with_new_image(admin_logged_in_page: Page, flask_app_u page.goto(f"{flask_app_url}/", timeout=600000) page.locator("a", has_text=test_title).click() - post_id = page.url.split('/')[-1] + post_id = page.url.split("/")[-1] page.goto(f"{flask_app_url}/edit/{post_id}", timeout=600000) expect(page).to_have_title("Edit Post") image_path = os.path.join(os.path.dirname(__file__), "test_image_new.png") with open(image_path, "wb") as f: - f.write(b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\x0cIDATx\xda\xed\xc1\x01\x01\x00\x00\x00\xc2\xa0\xf7Om\x00\x00\x00\x00IEND\xaeB`\x82") + f.write( + b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\x0cIDATx\xda\xed\xc1\x01\x01\x00\x00\x00\xc2\xa0\xf7Om\x00\x00\x00\x00IEND\xaeB`\x82" + ) page.set_input_files("input[name='image']", image_path) page.click("button[type='submit']") @@ -93,7 +99,10 @@ def test_edit_post_update_with_new_image(admin_logged_in_page: Page, flask_app_u page.goto(f"{flask_app_url}/delete/{post_id}", timeout=600000) os.remove(image_path) -def test_edit_post_update_without_changing_image(admin_logged_in_page: Page, flask_app_url): + +def test_edit_post_update_without_changing_image( + admin_logged_in_page: Page, flask_app_url +): page = admin_logged_in_page page.goto(f"{flask_app_url}/new", timeout=600000) @@ -101,7 +110,9 @@ def test_edit_post_update_without_changing_image(admin_logged_in_page: Page, fla test_content = "Content for existing image." image_path = os.path.join(os.path.dirname(__file__), "test_image_original.png") with open(image_path, "wb") as f: - f.write(b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\x00IEND\xaeB`\x82") + f.write( + b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\x00IEND\xaeB`\x82" + ) page.fill("input[name='title']", test_title) page.fill("textarea[name='content']", test_content) @@ -111,7 +122,7 @@ def test_edit_post_update_without_changing_image(admin_logged_in_page: Page, fla page.goto(f"{flask_app_url}/", timeout=600000) page.locator("a", has_text=test_title).click() - post_id = page.url.split('/')[-1] + post_id = page.url.split("/")[-1] original_image_src = page.locator(".image").get_attribute("src") page.goto(f"{flask_app_url}/edit/{post_id}", timeout=600000) @@ -132,6 +143,7 @@ def test_edit_post_update_without_changing_image(admin_logged_in_page: Page, fla page.goto(f"{flask_app_url}/delete/{post_id}", timeout=600000) os.remove(image_path) + def test_edit_non_existent_post(admin_logged_in_page: Page, flask_app_url): page = admin_logged_in_page non_existent_post_id = 999999999 diff --git a/tests/test_video.py b/tests/test_video.py index 3af6770..4dcdae6 100644 --- a/tests/test_video.py +++ b/tests/test_video.py @@ -1,18 +1,22 @@ -import pytest -from playwright.sync_api import Page, expect import os import time +from playwright.sync_api import Page, expect + + def validate_test_video_file(): """Validates and returns the path to the test video file.""" source_filepath = "tests/assets/test_video.mp4" print(f"Current working directory: {os.getcwd()}") print(f"Checking for test video at: {os.path.abspath(source_filepath)}") if not os.path.exists(source_filepath): - raise FileNotFoundError(f"Test video file not found at {source_filepath}. Please ensure it exists.") - + raise FileNotFoundError( + f"Test video file not found at {source_filepath}. Please ensure it exists." + ) + return source_filepath + def test_create_post_with_video(admin_logged_in_page: Page, flask_app_url): """ Tests creating a post with a video, verifying processing, and cleanup. @@ -42,18 +46,20 @@ def test_create_post_with_video(admin_logged_in_page: Page, flask_app_url): video_player = post_locator.locator(".video-player") expect(video_player).to_be_visible(timeout=600000) - + initial_video_url = video_player.get_attribute("data-url") assert initial_video_url, "Video player should have a data-url attribute" - assert "upload" in initial_video_url, "Initial video URL should reference the uploaded file" - + assert "upload" in initial_video_url, ( + "Initial video URL should reference the uploaded file" + ) + # 3. Poll and wait for the video to be processed post_link = post_locator.locator("a.post-button").first post_url = f"{flask_app_url}{post_link.get_attribute('href')}" - post_id = post_url.split('/')[-1] - + post_id = post_url.split("/")[-1] + processing_complete = False - for i in range(120): # Poll for up to 120 seconds + for i in range(120): # Poll for up to 120 seconds page.goto(post_url, timeout=600000) player_on_post_page = page.locator(".video-player") status = player_on_post_page.get_attribute("data-status") @@ -61,46 +67,56 @@ def test_create_post_with_video(admin_logged_in_page: Page, flask_app_url): processing_complete = True break time.sleep(1) - print(f"Polling video status... Current: {status} (Attempt {i+1}/120)") + print(f"Polling video status... Current: {status} (Attempt {i + 1}/120)") - assert processing_complete, "Video processing did not complete within the timeout period." + assert processing_complete, ( + "Video processing did not complete within the timeout period." + ) # 4. Verify the final state on the post page page.goto(post_url, timeout=600000) final_player = page.locator(".video-player") expect(final_player).to_have_attribute("data-status", "processed") - + processed_video_url = final_player.get_attribute("data-url") assert processed_video_url, "Processed video player should have a data-url" - assert ".m3u8" in processed_video_url, "Processed video URL should be an HLS playlist" - assert processed_video_url != initial_video_url, "Video URL should have changed after processing" + assert ".m3u8" in processed_video_url, ( + "Processed video URL should be an HLS playlist" + ) + assert processed_video_url != initial_video_url, ( + "Video URL should have changed after processing" + ) # 5. Cleanup page.goto(f"{flask_app_url}/delete/{post_id}", timeout=600000) - expect(page.locator(f".post:has-text('{test_title}')")).not_to_be_visible(timeout=600000) - + expect(page.locator(f".post:has-text('{test_title}')")).not_to_be_visible( + timeout=600000 + ) + def test_edit_post_to_add_video(admin_logged_in_page: Page, flask_app_url): """ Tests adding a video to a post that initially has none. """ page = admin_logged_in_page - + # 1. Create a post without a video test_title = f"Add Video Test {time.time()}" page.goto(f"{flask_app_url}/new", timeout=600000) page.fill("input[name='title']", test_title) page.fill("textarea[name='content']", "This post will have a video added.") page.click("button[type='submit']") - + page.goto(f"{flask_app_url}/", timeout=600000) page.locator("a", has_text=test_title).click() - post_id = page.url.split('/')[-1] + post_id = page.url.split("/")[-1] # 2. Edit the post to add a video page.goto(f"{flask_app_url}/edit/{post_id}", timeout=600000) - expect(page.locator(".video-player")).not_to_be_visible(timeout=600000) # No video initially - + expect(page.locator(".video-player")).not_to_be_visible( + timeout=600000 + ) # No video initially + video_path = validate_test_video_file() page.set_input_files("input[name='video']", video_path) page.click("button[type='submit']") @@ -108,9 +124,9 @@ def test_edit_post_to_add_video(admin_logged_in_page: Page, flask_app_url): # 3. Verify the video was added and poll for processing completion expect(page).to_have_url(f"{flask_app_url}/", timeout=600000) page.goto(f"{flask_app_url}/post/{post_id}", timeout=600000) - + processing_complete = False - for i in range(120): # Poll for up to 120 seconds + for i in range(120): # Poll for up to 120 seconds page.goto(f"{flask_app_url}/post/{post_id}", timeout=600000) player_on_post_page = page.locator(".video-player") status = player_on_post_page.get_attribute("data-status") @@ -118,17 +134,25 @@ def test_edit_post_to_add_video(admin_logged_in_page: Page, flask_app_url): processing_complete = True break time.sleep(1) - print(f"Polling video status for edit... Current: {status} (Attempt {i+1}/120)") + print( + f"Polling video status for edit... Current: {status} (Attempt {i + 1}/120)" + ) - assert processing_complete, "Video processing did not complete within the timeout period after edit." + assert processing_complete, ( + "Video processing did not complete within the timeout period after edit." + ) page.goto(f"{flask_app_url}/post/{post_id}", timeout=600000) final_player = page.locator(".video-player") expect(final_player).to_have_attribute("data-status", "processed") - + processed_video_url = final_player.get_attribute("data-url") - assert processed_video_url, "Processed video player should have a data-url after edit" - assert ".m3u8" in processed_video_url, "Processed video URL should be an HLS playlist after edit" + assert processed_video_url, ( + "Processed video player should have a data-url after edit" + ) + assert ".m3u8" in processed_video_url, ( + "Processed video URL should be an HLS playlist after edit" + ) # 4. Cleanup page.goto(f"{flask_app_url}/delete/{post_id}", timeout=600000) diff --git a/tests/test_video_player.py b/tests/test_video_player.py index c90d430..c97ef1a 100644 --- a/tests/test_video_player.py +++ b/tests/test_video_player.py @@ -1,8 +1,10 @@ -import pytest -from playwright.sync_api import Page, expect import os -import time import re +import time + +import pytest +from playwright.sync_api import Page, expect + def validate_test_video_file(): """Validates MP4 file for testing.""" @@ -10,10 +12,13 @@ def validate_test_video_file(): print(f"Current working directory: {os.getcwd()}") print(f"Checking for test video at: {os.path.abspath(source_filepath)}") if not os.path.exists(source_filepath): - raise FileNotFoundError(f"Test video file not found at {source_filepath}. Please ensure it exists.") - + raise FileNotFoundError( + f"Test video file not found at {source_filepath}. Please ensure it exists." + ) + return source_filepath + @pytest.fixture(scope="function") def post_with_processed_video(admin_logged_in_page: Page, flask_app_url): """ @@ -22,7 +27,7 @@ def post_with_processed_video(admin_logged_in_page: Page, flask_app_url): """ page = admin_logged_in_page page.goto(f"{flask_app_url}/new", timeout=600000) - + test_title = f"Video Player Test Post {time.time()}" video_path = validate_test_video_file() @@ -31,25 +36,25 @@ def post_with_processed_video(admin_logged_in_page: Page, flask_app_url): page.fill("textarea[name='content']", "Video player test content.") page.set_input_files("input[name='video']", video_path) page.click("button[type='submit']") - + # Go to homepage to find the post link page.goto(f"{flask_app_url}/", timeout=600000) post_locator = page.locator(f".post:has-text('{test_title}')") expect(post_locator).to_be_visible(timeout=600000) post_link = post_locator.locator("a.post-button").first post_url = f"{flask_app_url}{post_link.get_attribute('href')}" - post_id = post_url.split('/')[-1] + post_id = post_url.split("/")[-1] # Poll for video processing processing_complete = False - for i in range(120): # Poll for up to 2 minutes + for i in range(120): # Poll for up to 2 minutes page.goto(post_url, timeout=600000) player = page.locator(".video-player") status = player.get_attribute("data-status") if status == "processed": processing_complete = True break - print(f"Polling video status... Current: {status} (Attempt {i+1}/120)") + print(f"Polling video status... Current: {status} (Attempt {i + 1}/120)") time.sleep(1) if not processing_complete: @@ -75,9 +80,9 @@ def test_video_player_volume_and_mute(page: Page, post_with_processed_video: str # 1. Test Mute expect(video_player).not_to_have_js_property("muted", True) expect(volume_icon).to_have_attribute("src", "/static/svg/volume-up.svg") - + mute_btn.click() - + expect(video_player).to_have_js_property("muted", True) expect(volume_icon).to_have_attribute("src", "/static/svg/volume-mute.svg") @@ -89,15 +94,19 @@ def test_video_player_volume_and_mute(page: Page, post_with_processed_video: str # 3. Test Volume Slider initial_volume = float(video_player.evaluate("player => player.volume")) - assert initial_volume > 0.9 # Should be close to 1 + assert initial_volume > 0.9 # Should be close to 1 # Set volume to 50% - volume_slider.evaluate("slider => { slider.value = 50; slider.dispatchEvent(new Event('input')); }") + volume_slider.evaluate( + "slider => { slider.value = 50; slider.dispatchEvent(new Event('input')); }" + ) expect(video_player).to_have_js_property("volume", 0.5) expect(volume_icon).to_have_attribute("src", "/static/svg/volume-up.svg") # Set volume to 0% - volume_slider.evaluate("slider => { slider.value = 0; slider.dispatchEvent(new Event('input')); }") + volume_slider.evaluate( + "slider => { slider.value = 0; slider.dispatchEvent(new Event('input')); }" + ) expect(video_player).to_have_js_property("muted", True) expect(volume_icon).to_have_attribute("src", "/static/svg/volume-mute.svg") @@ -119,6 +128,6 @@ def test_video_player_fullscreen(page: Page, post_with_processed_video: str): # This will not actually make the browser window fullscreen in headless mode, # but we can check if the button icon and class change as expected. fullscreen_btn.click() - + expect(fullscreen_icon).to_have_attribute("src", "/static/svg/fullscreen-exit.svg") expect(video_container).to_have_class(re.compile(r"\bfullscreen\b")) diff --git a/tests/test_viewer.py b/tests/test_viewer.py index 871e83b..68205ad 100644 --- a/tests/test_viewer.py +++ b/tests/test_viewer.py @@ -1,38 +1,39 @@ -import pytest -from playwright.sync_api import Page, expect import os -import time import re -import math +import time + +from playwright.sync_api import Page, expect + def create_test_post_with_image(page: Page, flask_app_url: str): """Helper function to create a post with an image.""" page.goto(f"{flask_app_url}/new", timeout=600000) - + test_title = f"Test Post with Image {time.time()}" test_content = "This post has an image for viewer testing." - + image = os.path.join(os.path.dirname(__file__), "test_viewer_image.png") page.fill("input[name='title']", test_title) page.fill("textarea[name='content']", test_content) page.set_input_files("input[name='image']", image) page.click("button[type='submit']") - + page.goto(f"{flask_app_url}/", timeout=600000) page.locator("h1", has_text=test_title).click() - post_id = page.url.split('/')[-1] + post_id = page.url.split("/")[-1] page.goto(f"{flask_app_url}/", timeout=600000) return test_title, post_id + def test_image_viewer_on_homepage(admin_logged_in_page: Page, flask_app_url: str): """Test that the image viewer opens on the homepage.""" page = admin_logged_in_page test_title, post_id = create_test_post_with_image(page, flask_app_url) # Find the post and click the image - post_locator = page.locator(f".post:has(h1:has-text(\"{test_title}\"))") + post_locator = page.locator(f'.post:has(h1:has-text("{test_title}"))') image_locator = post_locator.locator("img") expect(image_locator).to_be_visible(timeout=600000) image_locator.click() @@ -44,13 +45,14 @@ def test_image_viewer_on_homepage(admin_logged_in_page: Page, flask_app_url: str page.goto(f"{flask_app_url}/delete/{post_id}", timeout=600000) + def test_image_viewer_on_post_page(admin_logged_in_page: Page, flask_app_url: str): """Test that the image viewer opens on the post page.""" page = admin_logged_in_page test_title, post_id = create_test_post_with_image(page, flask_app_url) # Navigate to the post page - page.locator(f"a:has-text(\"{test_title}\")").click() + page.locator(f'a:has-text("{test_title}")').click() # Click the image on the post page image_locator = page.locator(".gallery img") @@ -64,6 +66,7 @@ def test_image_viewer_on_post_page(admin_logged_in_page: Page, flask_app_url: st page.goto(f"{flask_app_url}/delete/{post_id}", timeout=600000) + def test_image_viewer_zoom_scroll(admin_logged_in_page: Page, flask_app_url: str): """Test zoom functionality even when toolbar is disabled, using bounding box measurement.""" page = admin_logged_in_page @@ -78,7 +81,9 @@ def test_image_viewer_zoom_scroll(admin_logged_in_page: Page, flask_app_url: str expect(page.locator(".viewer-container")).to_be_visible(timeout=600000) # Select visible image inside the viewer - viewer_image = page.locator(".viewer-canvas img, .viewer-image, .viewer-move img").first + viewer_image = page.locator( + ".viewer-canvas img, .viewer-image, .viewer-move img" + ).first expect(viewer_image).to_be_visible(timeout=600000) # Get initial bounding box size diff --git a/uv.lock b/uv.lock index b3a9092..d2a0f57 100644 --- a/uv.lock +++ b/uv.lock @@ -66,6 +66,7 @@ dependencies = [ dev = [ { name = "playwright" }, { name = "pytest" }, + { name = "ruff" }, ] [package.metadata] @@ -85,6 +86,7 @@ requires-dist = [ dev = [ { name = "playwright", specifier = ">=1.55.0" }, { name = "pytest", specifier = ">=8.4.2" }, + { name = "ruff", specifier = ">=0.14.5" }, ] [[package]] @@ -330,6 +332,8 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/19/0d/6660d55f7373b2ff8152401a83e02084956da23ae58cddbfb0b330978fe9/greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0", size = 607586, upload-time = "2025-08-07T13:18:28.544Z" }, { url = "https://files.pythonhosted.org/packages/8e/1a/c953fdedd22d81ee4629afbb38d2f9d71e37d23caace44775a3a969147d4/greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0", size = 1123281, upload-time = "2025-08-07T13:42:39.858Z" }, { url = "https://files.pythonhosted.org/packages/3f/c7/12381b18e21aef2c6bd3a636da1088b888b97b7a0362fac2e4de92405f97/greenlet-3.2.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20fb936b4652b6e307b8f347665e2c615540d4b42b3b4c8a321d8286da7e520f", size = 1151142, upload-time = "2025-08-07T13:18:22.981Z" }, + { url = "https://files.pythonhosted.org/packages/27/45/80935968b53cfd3f33cf99ea5f08227f2646e044568c9b1555b58ffd61c2/greenlet-3.2.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee7a6ec486883397d70eec05059353b8e83eca9168b9f3f9a361971e77e0bcd0", size = 1564846, upload-time = "2025-11-04T12:42:15.191Z" }, + { url = "https://files.pythonhosted.org/packages/69/02/b7c30e5e04752cb4db6202a3858b149c0710e5453b71a3b2aec5d78a1aab/greenlet-3.2.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:326d234cbf337c9c3def0676412eb7040a35a768efc92504b947b3e9cfc7543d", size = 1633814, upload-time = "2025-11-04T12:42:17.175Z" }, { url = "https://files.pythonhosted.org/packages/e9/08/b0814846b79399e585f974bbeebf5580fbe59e258ea7be64d9dfb253c84f/greenlet-3.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7d4e128405eea3814a12cc2605e0e6aedb4035bf32697f72deca74de4105e02", size = 299899, upload-time = "2025-08-07T13:38:53.448Z" }, { url = "https://files.pythonhosted.org/packages/49/e8/58c7f85958bda41dafea50497cbd59738c5c43dbbea5ee83d651234398f4/greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31", size = 272814, upload-time = "2025-08-07T13:15:50.011Z" }, { url = "https://files.pythonhosted.org/packages/62/dd/b9f59862e9e257a16e4e610480cfffd29e3fae018a68c2332090b53aac3d/greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945", size = 641073, upload-time = "2025-08-07T13:42:57.23Z" }, @@ -339,6 +343,8 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ee/43/3cecdc0349359e1a527cbf2e3e28e5f8f06d3343aaf82ca13437a9aa290f/greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671", size = 610497, upload-time = "2025-08-07T13:18:31.636Z" }, { url = "https://files.pythonhosted.org/packages/b8/19/06b6cf5d604e2c382a6f31cafafd6f33d5dea706f4db7bdab184bad2b21d/greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", size = 1121662, upload-time = "2025-08-07T13:42:41.117Z" }, { url = "https://files.pythonhosted.org/packages/a2/15/0d5e4e1a66fab130d98168fe984c509249c833c1a3c16806b90f253ce7b9/greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae", size = 1149210, upload-time = "2025-08-07T13:18:24.072Z" }, + { url = "https://files.pythonhosted.org/packages/1c/53/f9c440463b3057485b8594d7a638bed53ba531165ef0ca0e6c364b5cc807/greenlet-3.2.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e343822feb58ac4d0a1211bd9399de2b3a04963ddeec21530fc426cc121f19b", size = 1564759, upload-time = "2025-11-04T12:42:19.395Z" }, + { url = "https://files.pythonhosted.org/packages/47/e4/3bb4240abdd0a8d23f4f88adec746a3099f0d86bfedb623f063b2e3b4df0/greenlet-3.2.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca7f6f1f2649b89ce02f6f229d7c19f680a6238af656f61e0115b24857917929", size = 1634288, upload-time = "2025-11-04T12:42:21.174Z" }, { url = "https://files.pythonhosted.org/packages/0b/55/2321e43595e6801e105fcfdee02b34c0f996eb71e6ddffca6b10b7e1d771/greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b", size = 299685, upload-time = "2025-08-07T13:24:38.824Z" }, { url = "https://files.pythonhosted.org/packages/22/5c/85273fd7cc388285632b0498dbbab97596e04b154933dfe0f3e68156c68c/greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0", size = 273586, upload-time = "2025-08-07T13:16:08.004Z" }, { url = "https://files.pythonhosted.org/packages/d1/75/10aeeaa3da9332c2e761e4c50d4c3556c21113ee3f0afa2cf5769946f7a3/greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f", size = 686346, upload-time = "2025-08-07T13:42:59.944Z" }, @@ -346,6 +352,8 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/8b/29aae55436521f1d6f8ff4e12fb676f3400de7fcf27fccd1d4d17fd8fecd/greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1", size = 694659, upload-time = "2025-08-07T13:53:17.759Z" }, { url = "https://files.pythonhosted.org/packages/92/2e/ea25914b1ebfde93b6fc4ff46d6864564fba59024e928bdc7de475affc25/greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735", size = 695355, upload-time = "2025-08-07T13:18:34.517Z" }, { url = "https://files.pythonhosted.org/packages/72/60/fc56c62046ec17f6b0d3060564562c64c862948c9d4bc8aa807cf5bd74f4/greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337", size = 657512, upload-time = "2025-08-07T13:18:33.969Z" }, + { url = "https://files.pythonhosted.org/packages/23/6e/74407aed965a4ab6ddd93a7ded3180b730d281c77b765788419484cdfeef/greenlet-3.2.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2917bdf657f5859fbf3386b12d68ede4cf1f04c90c3a6bc1f013dd68a22e2269", size = 1612508, upload-time = "2025-11-04T12:42:23.427Z" }, + { url = "https://files.pythonhosted.org/packages/0d/da/343cd760ab2f92bac1845ca07ee3faea9fe52bee65f7bcb19f16ad7de08b/greenlet-3.2.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:015d48959d4add5d6c9f6c5210ee3803a830dce46356e3bc326d6776bde54681", size = 1680760, upload-time = "2025-11-04T12:42:25.341Z" }, { url = "https://files.pythonhosted.org/packages/e3/a5/6ddab2b4c112be95601c13428db1d8b6608a8b6039816f2ba09c346c08fc/greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01", size = 303425, upload-time = "2025-08-07T13:32:27.59Z" }, ] @@ -770,8 +778,10 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2d/75/364847b879eb630b3ac8293798e380e441a957c53657995053c5ec39a316/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ab8905b5dcb05bf3fb22e0cf90e10f469563486ffb6a96569e51f897c750a76a", size = 4411159, upload-time = "2025-10-10T11:12:00.49Z" }, { url = "https://files.pythonhosted.org/packages/6f/a0/567f7ea38b6e1c62aafd58375665a547c00c608a471620c0edc364733e13/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf940cd7e7fec19181fdbc29d76911741153d51cab52e5c21165f3262125685e", size = 4468234, upload-time = "2025-10-10T11:12:04.892Z" }, { url = "https://files.pythonhosted.org/packages/30/da/4e42788fb811bbbfd7b7f045570c062f49e350e1d1f3df056c3fb5763353/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fa0f693d3c68ae925966f0b14b8edda71696608039f4ed61b1fe9ffa468d16db", size = 4166236, upload-time = "2025-10-10T11:12:11.674Z" }, + { url = "https://files.pythonhosted.org/packages/3c/94/c1777c355bc560992af848d98216148be5f1be001af06e06fc49cbded578/psycopg2_binary-2.9.11-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a1cf393f1cdaf6a9b57c0a719a1068ba1069f022a59b8b1fe44b006745b59757", size = 3983083, upload-time = "2025-10-30T02:55:15.73Z" }, { url = "https://files.pythonhosted.org/packages/bd/42/c9a21edf0e3daa7825ed04a4a8588686c6c14904344344a039556d78aa58/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7a6beb4beaa62f88592ccc65df20328029d721db309cb3250b0aae0fa146c3", size = 3652281, upload-time = "2025-10-10T11:12:17.713Z" }, { url = "https://files.pythonhosted.org/packages/12/22/dedfbcfa97917982301496b6b5e5e6c5531d1f35dd2b488b08d1ebc52482/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:31b32c457a6025e74d233957cc9736742ac5a6cb196c6b68499f6bb51390bd6a", size = 3298010, upload-time = "2025-10-10T11:12:22.671Z" }, + { url = "https://files.pythonhosted.org/packages/66/ea/d3390e6696276078bd01b2ece417deac954dfdd552d2edc3d03204416c0c/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:edcb3aeb11cb4bf13a2af3c53a15b3d612edeb6409047ea0b5d6a21a9d744b34", size = 3044641, upload-time = "2025-10-30T02:55:19.929Z" }, { url = "https://files.pythonhosted.org/packages/12/9a/0402ded6cbd321da0c0ba7d34dc12b29b14f5764c2fc10750daa38e825fc/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:62b6d93d7c0b61a1dd6197d208ab613eb7dcfdcca0a49c42ceb082257991de9d", size = 3347940, upload-time = "2025-10-10T11:12:26.529Z" }, { url = "https://files.pythonhosted.org/packages/b1/d2/99b55e85832ccde77b211738ff3925a5d73ad183c0b37bcbbe5a8ff04978/psycopg2_binary-2.9.11-cp312-cp312-win_amd64.whl", hash = "sha256:b33fabeb1fde21180479b2d4667e994de7bbf0eec22832ba5d9b5e4cf65b6c6d", size = 2714147, upload-time = "2025-10-10T11:12:29.535Z" }, { url = "https://files.pythonhosted.org/packages/ff/a8/a2709681b3ac11b0b1786def10006b8995125ba268c9a54bea6f5ae8bd3e/psycopg2_binary-2.9.11-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b8fb3db325435d34235b044b199e56cdf9ff41223a4b9752e8576465170bb38c", size = 3756572, upload-time = "2025-10-10T11:12:32.873Z" }, @@ -779,8 +789,10 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/11/32/b2ffe8f3853c181e88f0a157c5fb4e383102238d73c52ac6d93a5c8bffe6/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c55b385daa2f92cb64b12ec4536c66954ac53654c7f15a203578da4e78105c0", size = 4411242, upload-time = "2025-10-10T11:12:42.388Z" }, { url = "https://files.pythonhosted.org/packages/10/04/6ca7477e6160ae258dc96f67c371157776564679aefd247b66f4661501a2/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c0377174bf1dd416993d16edc15357f6eb17ac998244cca19bc67cdc0e2e5766", size = 4468258, upload-time = "2025-10-10T11:12:48.654Z" }, { url = "https://files.pythonhosted.org/packages/3c/7e/6a1a38f86412df101435809f225d57c1a021307dd0689f7a5e7fe83588b1/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5c6ff3335ce08c75afaed19e08699e8aacf95d4a260b495a4a8545244fe2ceb3", size = 4166295, upload-time = "2025-10-10T11:12:52.525Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7d/c07374c501b45f3579a9eb761cbf2604ddef3d96ad48679112c2c5aa9c25/psycopg2_binary-2.9.11-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:84011ba3109e06ac412f95399b704d3d6950e386b7994475b231cf61eec2fc1f", size = 3983133, upload-time = "2025-10-30T02:55:24.329Z" }, { url = "https://files.pythonhosted.org/packages/82/56/993b7104cb8345ad7d4516538ccf8f0d0ac640b1ebd8c754a7b024e76878/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ba34475ceb08cccbdd98f6b46916917ae6eeb92b5ae111df10b544c3a4621dc4", size = 3652383, upload-time = "2025-10-10T11:12:56.387Z" }, { url = "https://files.pythonhosted.org/packages/2d/ac/eaeb6029362fd8d454a27374d84c6866c82c33bfc24587b4face5a8e43ef/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b31e90fdd0f968c2de3b26ab014314fe814225b6c324f770952f7d38abf17e3c", size = 3298168, upload-time = "2025-10-10T11:13:00.403Z" }, + { url = "https://files.pythonhosted.org/packages/2b/39/50c3facc66bded9ada5cbc0de867499a703dc6bca6be03070b4e3b65da6c/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d526864e0f67f74937a8fce859bd56c979f5e2ec57ca7c627f5f1071ef7fee60", size = 3044712, upload-time = "2025-10-30T02:55:27.975Z" }, { url = "https://files.pythonhosted.org/packages/9c/8e/b7de019a1f562f72ada81081a12823d3c1590bedc48d7d2559410a2763fe/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04195548662fa544626c8ea0f06561eb6203f1984ba5b4562764fbeb4c3d14b1", size = 3347549, upload-time = "2025-10-10T11:13:03.971Z" }, { url = "https://files.pythonhosted.org/packages/80/2d/1bb683f64737bbb1f86c82b7359db1eb2be4e2c0c13b947f80efefa7d3e5/psycopg2_binary-2.9.11-cp313-cp313-win_amd64.whl", hash = "sha256:efff12b432179443f54e230fdf60de1f6cc726b6c832db8701227d089310e8aa", size = 2714215, upload-time = "2025-10-10T11:13:07.14Z" }, { url = "https://files.pythonhosted.org/packages/64/12/93ef0098590cf51d9732b4f139533732565704f45bdc1ffa741b7c95fb54/psycopg2_binary-2.9.11-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:92e3b669236327083a2e33ccfa0d320dd01b9803b3e14dd986a4fc54aa00f4e1", size = 3756567, upload-time = "2025-10-10T11:13:11.885Z" }, @@ -788,8 +800,10 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/13/1e/98874ce72fd29cbde93209977b196a2edae03f8490d1bd8158e7f1daf3a0/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9b52a3f9bb540a3e4ec0f6ba6d31339727b2950c9772850d6545b7eae0b9d7c5", size = 4411646, upload-time = "2025-10-10T11:13:24.432Z" }, { url = "https://files.pythonhosted.org/packages/5a/bd/a335ce6645334fb8d758cc358810defca14a1d19ffbc8a10bd38a2328565/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:db4fd476874ccfdbb630a54426964959e58da4c61c9feba73e6094d51303d7d8", size = 4468701, upload-time = "2025-10-10T11:13:29.266Z" }, { url = "https://files.pythonhosted.org/packages/44/d6/c8b4f53f34e295e45709b7568bf9b9407a612ea30387d35eb9fa84f269b4/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:47f212c1d3be608a12937cc131bd85502954398aaa1320cb4c14421a0ffccf4c", size = 4166293, upload-time = "2025-10-10T11:13:33.336Z" }, + { url = "https://files.pythonhosted.org/packages/4b/e0/f8cc36eadd1b716ab36bb290618a3292e009867e5c97ce4aba908cb99644/psycopg2_binary-2.9.11-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e35b7abae2b0adab776add56111df1735ccc71406e56203515e228a8dc07089f", size = 3983184, upload-time = "2025-10-30T02:55:32.483Z" }, { url = "https://files.pythonhosted.org/packages/53/3e/2a8fe18a4e61cfb3417da67b6318e12691772c0696d79434184a511906dc/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fcf21be3ce5f5659daefd2b3b3b6e4727b028221ddc94e6c1523425579664747", size = 3652650, upload-time = "2025-10-10T11:13:38.181Z" }, { url = "https://files.pythonhosted.org/packages/76/36/03801461b31b29fe58d228c24388f999fe814dfc302856e0d17f97d7c54d/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9bd81e64e8de111237737b29d68039b9c813bdf520156af36d26819c9a979e5f", size = 3298663, upload-time = "2025-10-10T11:13:44.878Z" }, + { url = "https://files.pythonhosted.org/packages/97/77/21b0ea2e1a73aa5fa9222b2a6b8ba325c43c3a8d54272839c991f2345656/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:32770a4d666fbdafab017086655bcddab791d7cb260a16679cc5a7338b64343b", size = 3044737, upload-time = "2025-10-30T02:55:35.69Z" }, { url = "https://files.pythonhosted.org/packages/67/69/f36abe5f118c1dca6d3726ceae164b9356985805480731ac6712a63f24f0/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3cb3a676873d7506825221045bd70e0427c905b9c8ee8d6acd70cfcbd6e576d", size = 3347643, upload-time = "2025-10-10T11:13:53.499Z" }, { url = "https://files.pythonhosted.org/packages/e1/36/9c0c326fe3a4227953dfb29f5d0c8ae3b8eb8c1cd2967aa569f50cb3c61f/psycopg2_binary-2.9.11-cp314-cp314-win_amd64.whl", hash = "sha256:4012c9c954dfaccd28f94e84ab9f94e12df76b4afb22331b1f0d3154893a6316", size = 2803913, upload-time = "2025-10-10T11:13:57.058Z" }, ] @@ -995,6 +1009,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, ] +[[package]] +name = "ruff" +version = "0.14.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/fa/fbb67a5780ae0f704876cb8ac92d6d76da41da4dc72b7ed3565ab18f2f52/ruff-0.14.5.tar.gz", hash = "sha256:8d3b48d7d8aad423d3137af7ab6c8b1e38e4de104800f0d596990f6ada1a9fc1", size = 5615944, upload-time = "2025-11-13T19:58:51.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/31/c07e9c535248d10836a94e4f4e8c5a31a1beed6f169b31405b227872d4f4/ruff-0.14.5-py3-none-linux_armv6l.whl", hash = "sha256:f3b8248123b586de44a8018bcc9fefe31d23dda57a34e6f0e1e53bd51fd63594", size = 13171630, upload-time = "2025-11-13T19:57:54.894Z" }, + { url = "https://files.pythonhosted.org/packages/8e/5c/283c62516dca697cd604c2796d1487396b7a436b2f0ecc3fd412aca470e0/ruff-0.14.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f7a75236570318c7a30edd7f5491945f0169de738d945ca8784500b517163a72", size = 13413925, upload-time = "2025-11-13T19:57:59.181Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f3/aa319f4afc22cb6fcba2b9cdfc0f03bbf747e59ab7a8c5e90173857a1361/ruff-0.14.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6d146132d1ee115f8802356a2dc9a634dbf58184c51bff21f313e8cd1c74899a", size = 12574040, upload-time = "2025-11-13T19:58:02.056Z" }, + { url = "https://files.pythonhosted.org/packages/f9/7f/cb5845fcc7c7e88ed57f58670189fc2ff517fe2134c3821e77e29fd3b0c8/ruff-0.14.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2380596653dcd20b057794d55681571a257a42327da8894b93bbd6111aa801f", size = 13009755, upload-time = "2025-11-13T19:58:05.172Z" }, + { url = "https://files.pythonhosted.org/packages/21/d2/bcbedbb6bcb9253085981730687ddc0cc7b2e18e8dc13cf4453de905d7a0/ruff-0.14.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2d1fa985a42b1f075a098fa1ab9d472b712bdb17ad87a8ec86e45e7fa6273e68", size = 12937641, upload-time = "2025-11-13T19:58:08.345Z" }, + { url = "https://files.pythonhosted.org/packages/a4/58/e25de28a572bdd60ffc6bb71fc7fd25a94ec6a076942e372437649cbb02a/ruff-0.14.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88f0770d42b7fa02bbefddde15d235ca3aa24e2f0137388cc15b2dcbb1f7c7a7", size = 13610854, upload-time = "2025-11-13T19:58:11.419Z" }, + { url = "https://files.pythonhosted.org/packages/7d/24/43bb3fd23ecee9861970978ea1a7a63e12a204d319248a7e8af539984280/ruff-0.14.5-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:3676cb02b9061fee7294661071c4709fa21419ea9176087cb77e64410926eb78", size = 15061088, upload-time = "2025-11-13T19:58:14.551Z" }, + { url = "https://files.pythonhosted.org/packages/23/44/a022f288d61c2f8c8645b24c364b719aee293ffc7d633a2ca4d116b9c716/ruff-0.14.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b595bedf6bc9cab647c4a173a61acf4f1ac5f2b545203ba82f30fcb10b0318fb", size = 14734717, upload-time = "2025-11-13T19:58:17.518Z" }, + { url = "https://files.pythonhosted.org/packages/58/81/5c6ba44de7e44c91f68073e0658109d8373b0590940efe5bd7753a2585a3/ruff-0.14.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f55382725ad0bdb2e8ee2babcbbfb16f124f5a59496a2f6a46f1d9d99d93e6e2", size = 14028812, upload-time = "2025-11-13T19:58:20.533Z" }, + { url = "https://files.pythonhosted.org/packages/ad/ef/41a8b60f8462cb320f68615b00299ebb12660097c952c600c762078420f8/ruff-0.14.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7497d19dce23976bdaca24345ae131a1d38dcfe1b0850ad8e9e6e4fa321a6e19", size = 13825656, upload-time = "2025-11-13T19:58:23.345Z" }, + { url = "https://files.pythonhosted.org/packages/7c/00/207e5de737fdb59b39eb1fac806904fe05681981b46d6a6db9468501062e/ruff-0.14.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:410e781f1122d6be4f446981dd479470af86537fb0b8857f27a6e872f65a38e4", size = 13959922, upload-time = "2025-11-13T19:58:26.537Z" }, + { url = "https://files.pythonhosted.org/packages/bc/7e/fa1f5c2776db4be405040293618846a2dece5c70b050874c2d1f10f24776/ruff-0.14.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c01be527ef4c91a6d55e53b337bfe2c0f82af024cc1a33c44792d6844e2331e1", size = 12932501, upload-time = "2025-11-13T19:58:29.822Z" }, + { url = "https://files.pythonhosted.org/packages/67/d8/d86bf784d693a764b59479a6bbdc9515ae42c340a5dc5ab1dabef847bfaa/ruff-0.14.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f66e9bb762e68d66e48550b59c74314168ebb46199886c5c5aa0b0fbcc81b151", size = 12927319, upload-time = "2025-11-13T19:58:32.923Z" }, + { url = "https://files.pythonhosted.org/packages/ac/de/ee0b304d450ae007ce0cb3e455fe24fbcaaedae4ebaad6c23831c6663651/ruff-0.14.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d93be8f1fa01022337f1f8f3bcaa7ffee2d0b03f00922c45c2207954f351f465", size = 13206209, upload-time = "2025-11-13T19:58:35.952Z" }, + { url = "https://files.pythonhosted.org/packages/33/aa/193ca7e3a92d74f17d9d5771a765965d2cf42c86e6f0fd95b13969115723/ruff-0.14.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:c135d4b681f7401fe0e7312017e41aba9b3160861105726b76cfa14bc25aa367", size = 13953709, upload-time = "2025-11-13T19:58:39.002Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f1/7119e42aa1d3bf036ffc9478885c2e248812b7de9abea4eae89163d2929d/ruff-0.14.5-py3-none-win32.whl", hash = "sha256:c83642e6fccfb6dea8b785eb9f456800dcd6a63f362238af5fc0c83d027dd08b", size = 12925808, upload-time = "2025-11-13T19:58:42.779Z" }, + { url = "https://files.pythonhosted.org/packages/3b/9d/7c0a255d21e0912114784e4a96bf62af0618e2190cae468cd82b13625ad2/ruff-0.14.5-py3-none-win_amd64.whl", hash = "sha256:9d55d7af7166f143c94eae1db3312f9ea8f95a4defef1979ed516dbb38c27621", size = 14331546, upload-time = "2025-11-13T19:58:45.691Z" }, + { url = "https://files.pythonhosted.org/packages/e5/80/69756670caedcf3b9be597a6e12276a6cf6197076eb62aad0c608f8efce0/ruff-0.14.5-py3-none-win_arm64.whl", hash = "sha256:4b700459d4649e2594b31f20a9de33bc7c19976d4746d8d0798ad959621d64a4", size = 13433331, upload-time = "2025-11-13T19:58:48.434Z" }, +] + [[package]] name = "six" version = "1.17.0" diff --git a/worker.py b/worker.py index 69ada7b..945d6bc 100644 --- a/worker.py +++ b/worker.py @@ -1,43 +1,65 @@ -import os, supabase, uuid -from apscheduler.schedulers.background import BackgroundScheduler -from apscheduler.executors.pool import ThreadPoolExecutor +import os +import uuid from datetime import datetime + import requests - +import supabase +from apscheduler.executors.pool import ThreadPoolExecutor +from apscheduler.schedulers.background import BackgroundScheduler + + class Worker: - def __init__(self, SUPABASE_KEY, SUPABASE_URL, videos_bucket='video'): - self.upload_folder = os.path.join("/tmp", 'uploads') + def __init__(self, SUPABASE_KEY, SUPABASE_URL, videos_bucket="video"): + self.upload_folder = os.path.join("/tmp", "uploads") os.makedirs(self.upload_folder, exist_ok=True) - executors = {'default': ThreadPoolExecutor(max_workers=1)} + executors = {"default": ThreadPoolExecutor(max_workers=1)} self.scheduler = BackgroundScheduler(executors=executors) self.scheduler.start() self.SUPABASE_KEY = SUPABASE_KEY self.SUPABASE_URL = SUPABASE_URL if not self.SUPABASE_URL or not self.SUPABASE_KEY: - raise ValueError("SUPABASE_URL or SUPABASE_ANON_KEY is not set. Supabase operations will fail.") - self.supabase_client = supabase.create_client(self.SUPABASE_URL, self.SUPABASE_KEY) + raise ValueError( + "SUPABASE_URL or SUPABASE_ANON_KEY is not set. Supabase operations will fail." + ) + self.supabase_client = supabase.create_client( + self.SUPABASE_URL, self.SUPABASE_KEY + ) self.videos_bucket = videos_bucket def save_file(self, file): - filename = uuid.uuid4().hex + '.' + file.filename.split('.')[-1] + filename = uuid.uuid4().hex + "." + file.filename.split(".")[-1] filepath = f"{self.SUPABASE_URL}/storage/v1/object/public/{self.videos_bucket}/upload/{filename}" - self.supabase_client.storage.from_(self.videos_bucket).upload("upload"+ "/" + filename, file.read(), {"content-type": file.content_type}) - self.supabase_client.table('videos').insert({'filename': filename, 'filepath': filepath}).execute() - file_id = self.supabase_client.table('videos').select('id').eq('filepath', filepath).execute().data[0]['id'] - return {"message": "File uploaded successfully", "filename": filename, 'file_id': file_id} - + self.supabase_client.storage.from_(self.videos_bucket).upload( + "upload" + "/" + filename, file.read(), {"content-type": file.content_type} + ) + self.supabase_client.table("videos").insert( + {"filename": filename, "filepath": filepath} + ).execute() + file_id = ( + self.supabase_client.table("videos") + .select("id") + .eq("filepath", filepath) + .execute() + .data[0]["id"] + ) + return { + "message": "File uploaded successfully", + "filename": filename, + "file_id": file_id, + } + def queue_file(self, file_id): """Queue file for async processing.""" self.scheduler.add_job( func=self._process_file, - trigger='date', + trigger="date", run_date=datetime.now(), args=[file_id], misfire_grace_time=3600, coalesce=True, id=f"process_{file_id}", - replace_existing=True + replace_existing=True, ) def _upload_folder_to_supabase(self, folder_path: str, bucket_name): @@ -47,36 +69,60 @@ def _upload_folder_to_supabase(self, folder_path: str, bucket_name): file_path = os.path.join(root, file_name) # Calculate relative path inside output folder relative_path = os.path.relpath(file_path, start=folder_path) - upload_path = os.path.basename(folder_path) + "/" + relative_path.replace(os.path.sep, "/") # Normalize for Supabase + upload_path = ( + os.path.basename(folder_path) + + "/" + + relative_path.replace(os.path.sep, "/") + ) # Normalize for Supabase with open(file_path, "rb") as f: data = f.read() # Upload using Supabase storage bucket, preserving folder structure with relative_path - self.supabase_client.storage.from_(bucket_name).upload(upload_path, data, {'cacheControl': '3600'}) + self.supabase_client.storage.from_(bucket_name).upload( + upload_path, data, {"cacheControl": "3600"} + ) except Exception as e: print(f"Error uploading {file_path} to Supabase: {e}") def _process_file(self, file_id): - result = self.supabase_client.table('videos').select('filepath', 'filename').eq('id', file_id).execute() + result = ( + self.supabase_client.table("videos") + .select("filepath", "filename") + .eq("id", file_id) + .execute() + ) if not result.data: raise ValueError(f"No file found with id {file_id}") result = result.data[0] - filepath = result['filepath'] - filename = result['filename'] + filepath = result["filepath"] + filename = result["filename"] video_file_path = self.upload_folder + "/" + filename - with open(video_file_path, 'wb') as video_file: - video = self.supabase_client.storage.from_(self.videos_bucket).download("upload"+ "/" + filename) + with open(video_file_path, "wb") as video_file: + video = self.supabase_client.storage.from_(self.videos_bucket).download( + "upload" + "/" + filename + ) video_file.write(video) try: - self.supabase_client.table('videos').update({'status': 'processing'}).eq('id', file_id).execute() - with open(video_file_path, 'rb') as video: - res = requests.post(f'https://ffmpeg.pythonanywhere.com/upload/{file_id}', files={'file': video}) + self.supabase_client.table("videos").update({"status": "processing"}).eq( + "id", file_id + ).execute() + with open(video_file_path, "rb") as video: + res = requests.post( + f"https://ffmpeg.pythonanywhere.com/upload/{file_id}", + files={"file": video}, + ) if res.ok: file_path = res.json().get("master_playlist") else: file_path = filepath raise RuntimeError(f"Error processing video: {res.text}") - self.supabase_client.table('videos').update({'status': 'processed', 'filepath': file_path}).eq('id', file_id).execute() - self.supabase_client.storage.from_(self.videos_bucket).remove([f"upload/{filename}"]) + self.supabase_client.table("videos").update( + {"status": "processed", "filepath": file_path} + ).eq("id", file_id).execute() + self.supabase_client.storage.from_(self.videos_bucket).remove( + [f"upload/{filename}"] + ) except Exception as e: - self.supabase_client.table('videos').update({'status': 'failed'}).eq('id', file_id).execute() + self.supabase_client.table("videos").update({"status": "failed"}).eq( + "id", file_id + ).execute() raise RuntimeError(f"Error processing file: {e}")