diff --git a/Dockerfile b/Dockerfile index 0c7614ee..e2da4bc7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,7 +29,9 @@ RUN poetry install --only main # 'development' stage installs all dev deps and can be used to develop code. # For example using docker-compose to mount local volume under /app FROM python-base AS development -ENV FLASK_DEBUG=True +ENV UVICORN_HOST=0.0.0.0 \ + UVICORN_PORT=8080 \ + UVICORN_RELOAD=true # Copying poetry and venv into image COPY --from=builder-base $POETRY_HOME $POETRY_HOME @@ -44,15 +46,18 @@ WORKDIR /app COPY . . EXPOSE 8080 -CMD ["gunicorn", "app:app()", "-c", "gunicorn.conf.dev.py"] +CMD ["uvicorn", "app:app"] # 'production' stage uses the clean 'python-base' stage and copyies # in only our runtime deps that were installed in the 'builder-base' FROM python-base AS production +ENV UVICORN_HOST=0.0.0.0 \ + UVICORN_PORT=8080 \ + UVICORN_WORKERS=4 COPY --from=builder-base $VENV_PATH $VENV_PATH COPY ./ /app WORKDIR /app EXPOSE 8080 -CMD ["gunicorn", "app:app()", "-c", "gunicorn.conf.py"] +CMD ["uvicorn", "app:app"] diff --git a/app.py b/app.py index eca7d693..944989b2 100644 --- a/app.py +++ b/app.py @@ -1,52 +1,83 @@ import os import logging -from flask import Flask +from contextlib import asynccontextmanager +import httpx +from fastapi import FastAPI, Request +from fastapi.encoders import jsonable_encoder +from fastapi.responses import JSONResponse +from fastapi.exceptions import RequestValidationError from model import * from mongo import * -def app(): - # Create a flask app - app = Flask(__name__) - app.config['PREFERRED_URL_SCHEME'] = 'https' - app.url_map.strict_slashes = False - setup_smtp(app) - # Register flask blueprint - api2prefix = [ - (auth_api, '/auth'), - (profile_api, '/profile'), - (problem_api, '/problem'), - (submission_api, '/submission'), - (course_api, '/course'), - (homework_api, '/homework'), - (test_api, '/test'), - (ann_api, '/ann'), - (ranking_api, '/ranking'), - (post_api, '/post'), - (copycat_api, '/copycat'), - (health_api, '/health'), - (user_api, '/user'), - ] - for api, prefix in api2prefix: - app.register_blueprint(api, url_prefix=prefix) +@asynccontextmanager +async def lifespan(app: FastAPI): + app.state.http_client = httpx.Client(timeout=httpx.Timeout(5.0, read=30.0)) + yield + app.state.http_client.close() + +def create_app() -> FastAPI: + app = FastAPI(lifespan=lifespan) + + from model.utils.response import NOJException + + @app.exception_handler(NOJException) + async def noj_exception_handler(request: Request, exc: NOJException): + return JSONResponse( + { + 'status': 'err', + 'message': exc.message, + 'data': exc.data, + }, + status_code=exc.status_code, + ) + + @app.exception_handler(RequestValidationError) + async def validation_exception_handler(request: Request, + exc: RequestValidationError): + return JSONResponse( + { + 'status': 'err', + 'message': 'Invalid request body', + 'data': jsonable_encoder(exc.errors()), + }, + status_code=400, + ) + + app.include_router(auth_router, prefix='/auth') + app.include_router(profile_router, prefix='/profile') + app.include_router(problem_router, prefix='/problem') + app.include_router(submission_router, prefix='/submission') + app.include_router(course_router, prefix='/course') + app.include_router(homework_router, prefix='/homework') + app.include_router(test_router, prefix='/test') + app.include_router(ann_router, prefix='/ann') + app.include_router(ranking_router, prefix='/ranking') + app.include_router(post_router, prefix='/post') + app.include_router(copycat_router, prefix='/copycat') + app.include_router(health_router, prefix='/health') + app.include_router(user_router, prefix='/user') + app.include_router(user_options_router, prefix='/user') + + _seed_db() + setup_smtp() + + return app + + +def _seed_db(): if not User('first_admin'): ADMIN = { 'username': 'first_admin', 'password': 'firstpasswordforadmin', - 'email': 'i.am.first.admin@noj.tw' + 'email': 'i.am.first.admin@noj.tw', } PROFILE = { 'displayed_name': 'the first admin', - 'bio': 'I am super good!!!!!' + 'bio': 'I am super good!!!!!', } admin = User.signup(**ADMIN) - # TODO: use a single method to active. - # we won't call `activate` here because it required the - # course 'Public' should exist, but create a course - # also need a teacher. - # but at least make it can work now... - # admin.activate(PROFILE) admin.update( active=True, role=0, @@ -55,29 +86,18 @@ def app(): if not Course('Public'): Course.add_course('Public', 'first_admin') - if __name__ != '__main__': - logger = logging.getLogger('gunicorn.error') - app.logger.handlers = logger.handlers - app.logger.setLevel(logger.level) - - return app - -def setup_smtp(app: Flask): - logger = logging.getLogger('gunicorn.error') +def setup_smtp(): + logger = logging.getLogger(__name__) if os.getenv('SMTP_SERVER') is None: logger.info( - '\'SMTP_SERVER\' is not set. email-related function will be disabled' + "'SMTP_SERVER' is not set. email-related function will be disabled" ) return if os.getenv('SMTP_NOREPLY') is None: - raise RuntimeError('missing required configuration \'SMTP_NOREPLY\'') + raise RuntimeError("missing required configuration 'SMTP_NOREPLY'") if os.getenv('SMTP_NOREPLY_PASSWORD') is None: - logger.info('\'SMTP_NOREPLY\' set but \'SMTP_NOREPLY_PASSWORD\' not') - # config for external URLs - server_name = os.getenv('SERVER_NAME') - if server_name is None: - raise RuntimeError('missing required configuration \'SERVER_NAME\'') - app.config['SERVER_NAME'] = server_name - if (application_root := os.getenv('APPLICATION_ROOT')) is not None: - app.config['APPLICATION_ROOT'] = application_root + logger.info("'SMTP_NOREPLY' set but 'SMTP_NOREPLY_PASSWORD' not") + + +app = create_app() diff --git a/gunicorn.conf.dev.py b/gunicorn.conf.dev.py deleted file mode 100644 index e704d269..00000000 --- a/gunicorn.conf.dev.py +++ /dev/null @@ -1,7 +0,0 @@ -bind = '0.0.0.0:8080' -errorlog = 'gunicorn_error.log' -accesslog = 'logs/access.log' -loglevel = 'debug' -threads = 5 -worker_class = 'gthread' -reload = True diff --git a/gunicorn.conf.py b/gunicorn.conf.py deleted file mode 100644 index a7af00c6..00000000 --- a/gunicorn.conf.py +++ /dev/null @@ -1,6 +0,0 @@ -bind = '0.0.0.0:8080' -errorlog = 'gunicorn_error.log' -loglevel = 'debug' -workers = 12 -threads = 12 -worker_class = 'gthread' diff --git a/model/announcement.py b/model/announcement.py index 1c63b9e6..071f294e 100644 --- a/model/announcement.py +++ b/model/announcement.py @@ -1,24 +1,21 @@ from datetime import datetime -from flask import Blueprint +from typing import Optional +from fastapi import APIRouter, Depends from mongo import * from mongo.utils import * -from .auth import * +from .auth import login_required from .utils import * from .schemas import CreateAnnouncementBody, UpdateAnnouncementBody, DeleteAnnouncementBody -from .course import * +from .course import course_router -__all__ = ['ann_api'] +__all__ = ['ann_router'] -ann_api = Blueprint('ann_api', __name__) +ann_router = APIRouter() -@ann_api.route('/', methods=['GET']) -@ann_api.route('/', methods=['GET']) -def get_sys_ann(ann_id=None): - public_name = Course.get_public().course_name - anns = Announcement.ann_list(None, public_name) - data = [{ +def _format_ann(an): + return { 'annId': str(an.id), 'title': an.title, 'createTime': int(an.create_time.timestamp()), @@ -26,40 +23,49 @@ def get_sys_ann(ann_id=None): 'creator': an.creator.info, 'updater': an.updater.info, 'markdown': an.markdown, - 'pinned': an.pinned - } for an in anns if ann_id == None or str(an.id) == ann_id] + 'pinned': an.pinned, + } + + +@ann_router.get('') +@ann_router.get('/{ann_id}') +def get_sys_ann(ann_id: Optional[str] = None): + public_name = Course.get_public().course_name + anns = Announcement.ann_list(None, public_name) + data = [ + _format_ann(an) for an in anns + if ann_id is None or str(an.id) == ann_id + ] return HTTPResponse('Sys Ann bro', data=data) -@course_api.get('//ann') -@ann_api.get('//') -@login_required -def get_announcements(user, course_name=None, ann_id=None): - # Get an announcement list +@course_router.get('/{course_name}/ann') +def get_course_announcements(course_name: str, user=Depends(login_required)): try: - anns = Announcement.ann_list(user.obj, course_name or 'Public') + anns = Announcement.ann_list(user.obj, course_name) except (DoesNotExist, ValidationError): return HTTPError('Cannot Access a Announcement', 403) if anns is None: return HTTPError('Announcement Not Found', 404) - data = [{ - 'annId': str(an.id), - 'title': an.title, - 'createTime': int(an.create_time.timestamp()), - 'updateTime': int(an.update_time.timestamp()), - 'creator': an.creator.info, - 'updater': an.updater.info, - 'markdown': an.markdown, - 'pinned': an.pinned - } for an in anns if ann_id is None or str(an.id) == ann_id] + data = [_format_ann(an) for an in anns] return HTTPResponse('Announcement List', data=data) -@ann_api.post('/') -@login_required -@parse_body(CreateAnnouncementBody) -def create_announcement(user, body: CreateAnnouncementBody): - # Create a new announcement +@ann_router.get('/{course_name}/{ann_id}') +def get_ann_by_id(course_name: str, ann_id: str, user=Depends(login_required)): + try: + anns = Announcement.ann_list(user.obj, course_name) + except (DoesNotExist, ValidationError): + return HTTPError('Cannot Access a Announcement', 403) + if anns is None: + return HTTPError('Announcement Not Found', 404) + data = [_format_ann(an) for an in anns if str(an.id) == ann_id] + return HTTPResponse('Announcement List', data=data) + + +@ann_router.post('') +def create_announcement(body: CreateAnnouncementBody, + user=Depends(login_required)): try: ann = Announcement.new_ann( title=body.title, @@ -76,20 +82,17 @@ def create_announcement(user, body: CreateAnnouncementBody): return HTTPError('Failed to Create Announcement', 403) data = { 'annId': str(ann.id), - 'createTime': int(ann.create_time.timestamp()) + 'createTime': int(ann.create_time.timestamp()), } return HTTPResponse('Announcement Created', data=data) -@ann_api.put('/') -@login_required -@parse_body(UpdateAnnouncementBody) -def update_announcement(user, body: UpdateAnnouncementBody): - # Update an announcement +@ann_router.put('') +def update_announcement(body: UpdateAnnouncementBody, + user=Depends(login_required)): ann = Announcement(body.ann_id) if not ann: return HTTPError('Announcement Not Found', 404) - course = Course(ann.course) if not course.permission(user, Course.Permission.GRADE): return HTTPError('Failed to Update Announcement', 403) @@ -102,23 +105,18 @@ def update_announcement(user, body: UpdateAnnouncementBody): pinned=body.pinned, ) except ValidationError as ve: - return HTTPError( - 'Failed to Update Announcement', - 400, - data=ve.to_dict(), - ) + return HTTPError('Failed to Update Announcement', + 400, + data=ve.to_dict()) return HTTPResponse('Updated') -@ann_api.delete('/') -@login_required -@parse_body(DeleteAnnouncementBody) -def delete_announcement(user, body: DeleteAnnouncementBody): - # Delete an announcement +@ann_router.delete('') +def delete_announcement(body: DeleteAnnouncementBody, + user=Depends(login_required)): ann = Announcement(body.ann_id) if not ann: return HTTPError('Announcement Not Found', 404) - course = Course(ann.course) if not course.permission(user, Course.Permission.GRADE): return HTTPError('Failed to Delete Announcement', 403) diff --git a/model/auth.py b/model/auth.py index d949f1aa..4beadcdf 100644 --- a/model/auth.py +++ b/model/auth.py @@ -1,11 +1,12 @@ # Standard library -from functools import wraps from random import SystemRandom from typing import Optional import csv import io +import logging # Related third party imports -from flask import Blueprint, request, current_app, url_for +from fastapi import APIRouter, Cookie, Depends, Request +from .utils.request import get_ip # Local application from mongo import * from mongo import engine @@ -28,13 +29,15 @@ import string __all__ = ( - 'auth_api', + 'auth_router', 'login_required', 'identity_verify', 'get_verify_link', ) -auth_api = Blueprint('auth_api', __name__) +logger = logging.getLogger(__name__) + +auth_router = APIRouter() VERIFY_TEXT = '''\ Welcome! you've signed up successfully! @@ -47,83 +50,53 @@ ''' -def login_required(func): - '''Check if the user is login +def login_required(piann: str | None = Cookie(default=None)) -> User: + '''Check if the user is logged in. - Returns: - - A wrapped function + Raises: - 403 Not Logged In - 403 Invalid Token + - 403 Authorization Expired - 403 Inactive User ''' - - @wraps(func) - @Request.cookies(vars_dict={'token': 'piann'}) - def wrapper(token, *args, **kwargs): - if token is None: - return HTTPError('Not Logged In', 403) - json = jwt_decode(token) - if json is None or not json.get('secret'): - return HTTPError('Invalid Token', 403) - user = User(json['data']['username']) - if json['data'].get('userId') != user.user_id: - return HTTPError('Authorization Expired', 403) - if not user.active: - return HTTPError('Inactive User', 403) - kwargs['user'] = user - return func(*args, **kwargs) - - return wrapper + if piann is None: + raise NOJException('Not Logged In', 403) + json = jwt_decode(piann) + if json is None or not json.get('secret'): + raise NOJException('Invalid Token', 403) + user = User(json['data']['username']) + if json['data'].get('userId') != user.user_id: + raise NOJException('Authorization Expired', 403) + if not user.active: + raise NOJException('Inactive User', 403) + return user def identity_verify(*roles): - '''Verify a logged in user's identity - - You can find an example in `model/test.py` - ''' + '''Verify a logged-in user's identity against allowed roles.''' - def verify(func): + def dependency(user: User = Depends(login_required)) -> User: + if user.role not in roles: + raise NOJException('Insufficient Permissions', 403) + return user - @wraps(func) - @login_required - def wrapper(user, *args, **kwargs): - if user.role not in roles: - return HTTPError('Insufficient Permissions', 403) - kwargs['user'] = user - return func(*args, **kwargs) + return Depends(dependency) - return wrapper - return verify +def get_verify_link(user: User, request: Request) -> str: + return str(request.url_for('active_redirect', token=user.cookie)) -def get_verify_link(user: User) -> str: - return url_for( - 'auth_api.active_redirect', - _external=True, - token=user.cookie, - ) - - -@auth_api.get('/session') +@auth_router.get('/session') def logout(): - '''Logout a user. - Returns: - - 200 Logout Success - ''' + '''Logout a user.''' cookies = {'jwt': None, 'piann': None} return HTTPResponse('Goodbye', cookies=cookies) -@auth_api.post('/session') -@parse_body(LoginBody) -def login(body: LoginBody): - '''Login a user. - Returns: - - 400 Incomplete Data - - 403 Login Failed - ''' - ip_addr = request.headers.get('cf-connecting-ip', request.remote_addr) +@auth_router.post('/session') +def login(body: LoginBody, ip_addr: str = Depends(get_ip)): + '''Login a user.''' try: user = User.login(body.username, body.password, ip_addr) except DoesNotExist: @@ -134,9 +107,8 @@ def login(body: LoginBody): return HTTPResponse('Login Success', cookies=cookies) -@auth_api.post('/signup') -@parse_body(SignupBody) -def signup(body: SignupBody): +@auth_router.post('/signup') +def signup(body: SignupBody, request: Request): try: user = User.signup(body.username, body.password, body.email) except ValidationError as ve: @@ -145,18 +117,19 @@ def signup(body: SignupBody): return HTTPError('User Exists', 400) except ValueError: return HTTPError('Not Allowed Name', 400) - verify_link = get_verify_link(user) + verify_link = get_verify_link(user, request) text = VERIFY_TEXT.format(url=verify_link) html = VERIFY_HTML.format(url=verify_link) send_noreply([body.email], '[N-OJ] Varify Your Email', text, html) return HTTPResponse('Signup Success') -@auth_api.post('/change-password') -@login_required -@parse_body(ChangePasswordBody) -def change_password(user, body: ChangePasswordBody): - ip_addr = request.headers.get('cf-connecting-ip', request.remote_addr) +@auth_router.post('/change-password') +def change_password( + body: ChangePasswordBody, + user: User = Depends(login_required), + ip_addr: str = Depends(get_ip), +): try: User.login(user.username, body.old_password, ip_addr) except DoesNotExist: @@ -166,49 +139,45 @@ def change_password(user, body: ChangePasswordBody): return HTTPResponse('Password Has Been Changed', cookies=cookies) -@auth_api.post('/check/') -def check(item): - '''Checking when the user is registing. - ''' +@auth_router.post('/check/username') +def check_username(body: CheckUsernameBody): + try: + User.get_by_username(body.username) + except DoesNotExist: + return HTTPResponse('Username Can Be Used', data={'valid': 1}) + return HTTPResponse('User Exists', data={'valid': 0}) - @parse_body(CheckUsernameBody) - def check_username(body: CheckUsernameBody): - try: - User.get_by_username(body.username) - except DoesNotExist: - return HTTPResponse('Username Can Be Used', data={'valid': 1}) - return HTTPResponse('User Exists', data={'valid': 0}) - @parse_body(CheckEmailBody) - def check_email(body: CheckEmailBody): - try: - User.get_by_email(body.email) - except DoesNotExist: - return HTTPResponse('Email Can Be Used', data={'valid': 1}) - return HTTPResponse('Email Has Been Used', data={'valid': 0}) +@auth_router.post('/check/email') +def check_email(body: CheckEmailBody): + try: + User.get_by_email(body.email) + except DoesNotExist: + return HTTPResponse('Email Can Be Used', data={'valid': 1}) + return HTTPResponse('Email Has Been Used', data={'valid': 0}) - method = {'username': check_username, 'email': check_email}.get(item) - return method() if method else HTTPError('Ivalid Checking Type', 400) +@auth_router.post('/check/{item}') +def check_invalid(item: str): + return HTTPError('Ivalid Checking Type', 400) -@auth_api.post('/resend-email') -@parse_body(ResendEmailBody) -def resend_email(body: ResendEmailBody): + +@auth_router.post('/resend-email') +def resend_email(body: ResendEmailBody, request: Request): try: user = User.get_by_email(body.email) except DoesNotExist: return HTTPError('User Not Exists', 400) if user.active: return HTTPError('User Has Been Actived', 400) - verify_link = get_verify_link(user) + verify_link = get_verify_link(user, request) send_noreply([body.email], '[N-OJ] Varify Your Email', verify_link) return HTTPResponse('Email Has Been Resent') -@auth_api.get('/active/') -def active_redirect(token): - '''Redirect user to active page. - ''' +@auth_router.get('/active/{token}', name='active_redirect') +def active_redirect(token: str): + '''Redirect user to active page.''' json = jwt_decode(token) if json is None: return HTTPError('Invalid Token', 403) @@ -217,15 +186,15 @@ def active_redirect(token): return HTTPRedirect('/email_verify', cookies=cookies) -@auth_api.post('/active') -@parse_body(ActivateUserBody) -@Request.cookies(vars_dict={'token': 'piann'}) -def activate_user(body: ActivateUserBody, token): - '''User: active: false -> true - ''' +@auth_router.post('/active') +def activate_user( + body: ActivateUserBody, + piann: str | None = Cookie(default=None), +): + '''User: active: false -> true''' if body.agreement is not True: return HTTPError('Not Confirm the Agreement', 403) - json = jwt_decode(token) + json = jwt_decode(piann) if json is None or not json.get('secret'): return HTTPError('Invalid Token.', 403) user = User(json['data']['username']) @@ -241,8 +210,7 @@ def activate_user(body: ActivateUserBody, token): return HTTPResponse('User Is Now Active', cookies=cookies) -@auth_api.post('/password-recovery') -@parse_body(PasswordRecoveryBody) +@auth_router.post('/password-recovery') def password_recovery(body: PasswordRecoveryBody): email = body.email try: @@ -261,14 +229,12 @@ def password_recovery(body: PasswordRecoveryBody): return HTTPResponse('Recovery Email Has Been Sent') -@auth_api.post('/user') -@parse_body(AuthAddUserBody) -@identity_verify(0) -def add_user(user, body: AuthAddUserBody): - ''' - Directly add a user without activation required. - This operation only allow admin to use. - ''' +@auth_router.post('/user') +def add_user( + body: AuthAddUserBody, + user: User = identity_verify(0), +): + '''Directly add a user without activation. Admin only.''' try: User.signup( body.username, @@ -284,10 +250,11 @@ def add_user(user, body: AuthAddUserBody): return HTTPResponse() -@auth_api.post('/batch-signup') -@parse_body(BatchSignupBody) -@identity_verify(0) -def batch_signup(user, body: BatchSignupBody): +@auth_router.post('/batch-signup') +def batch_signup( + body: BatchSignupBody, + user: User = identity_verify(0), +): course = None if body.course is not None: try: @@ -299,7 +266,7 @@ def batch_signup(user, body: BatchSignupBody): try: new_users = [*csv.DictReader(io.StringIO(body.new_users))] except csv.Error as e: - current_app.logger.info(f'Error parse csv file [err={e}]') + logger.info(f'Error parse csv file [err={e}]') return HTTPError('Invalid file content', 400) force = body.force if body.force is not None else False try: @@ -313,10 +280,11 @@ def batch_signup(user, body: BatchSignupBody): return HTTPResponse() -@auth_api.get('/me') -@parse_query(GetMeQuery) -@login_required -def get_me(user: User, query: GetMeQuery): +@auth_router.get('/me') +def get_me( + query: GetMeQuery = Depends(), + user: User = Depends(login_required), +): fields = query.fields default = [ 'username', diff --git a/model/copycat.py b/model/copycat.py index d4979073..307e9dda 100644 --- a/model/copycat.py +++ b/model/copycat.py @@ -1,37 +1,37 @@ +import os +import re +import logging +import threading from typing import Dict -from flask import Blueprint, request, current_app +import httpx +import mosspy +from fastapi import APIRouter, Depends + from mongo import * from mongo.utils import * from .utils import * -from .auth import * +from .auth import login_required from .schemas import GetReportQuery, DetectBody -import mosspy -import threading -import logging -import requests -import re +__all__ = ['copycat_router'] -__all__ = ['copycat_api'] - -copycat_api = Blueprint('copycat_api', __name__) +copycat_router = APIRouter() def is_valid_url(url): - import re regex = re.compile( - r'^https?://' # http:// or https:// - r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+[A-Z]{2,6}\.?|' # domain... - r'localhost|' # localhost... - r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})' # ...or ip - r'(?::\d+)?' # optional port + r'^https?://' + r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+[A-Z]{2,6}\.?|' + r'localhost|' + r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})' + r'(?::\d+)?' r'(?:/?|[/?]\S+)$', - re.IGNORECASE) + re.IGNORECASE, + ) return url is not None and regex.search(url) def get_report_task(user, problem_id, student_dict: Dict): - # select all ac code submissions = Submission.filter( user=user, offset=0, @@ -45,33 +45,27 @@ def get_report_task(user, problem_id, student_dict: Dict): for submission in submissions: s = Submission(submission.id) if s.user.username in student_dict: - if s.language in [0, 1] \ - and s.user.username not in last_cc_submission: + if s.language in [0, 1 + ] and s.user.username not in last_cc_submission: last_cc_submission[ submission.user.username] = s.main_code_path() - elif s.language in [2] \ - and s.user.username not in last_python_submission: + elif s.language in [ + 2 + ] and s.user.username not in last_python_submission: last_python_submission[ submission.user.username] = s.main_code_path() moss_userid = 97089070 - - # get logger - logger = logging.getLogger('guincorn.error') - - # Get problem object + logger = logging.getLogger(__name__) problem = Problem(problem_id) cpp_report_url = '' python_report_url = '' - # check for c or cpp code if problem.allowed_language != 4: m1 = mosspy.Moss(moss_userid, "cc") - for user, code_path in last_cc_submission.items(): logger.info(f'send {user} {code_path}') m1.addFile(code_path) - response = m1.send() if is_valid_url(response): cpp_report_url = response @@ -79,14 +73,11 @@ def get_report_task(user, problem_id, student_dict: Dict): logger.info(f"[copycat] {response}") cpp_report_url = '' - # check for python code if problem.allowed_language >= 4: m2 = mosspy.Moss(moss_userid, "python") - for user, code_path in last_python_submission.items(): logger.info(f'send {user} {code_path}') m2.addFile(code_path) - response = m2.send() if is_valid_url(response): python_report_url = response @@ -94,7 +85,6 @@ def get_report_task(user, problem_id, student_dict: Dict): logger.info(f"[copycat] {response}") python_report_url = '' - # download report from moss if cpp_report_url != '': mosspy.download_report( cpp_report_url, @@ -110,7 +100,6 @@ def get_report_task(user, problem_id, student_dict: Dict): log_level=10, ) - # insert report url into DB & update status problem.obj.update( cpp_report_url=cpp_report_url, python_report_url=python_report_url, @@ -120,28 +109,23 @@ def get_report_task(user, problem_id, student_dict: Dict): def get_report_by_url(url: str): try: - response = requests.get(url) + response = httpx.get(url) return response.text - except (requests.exceptions.MissingSchema, - requests.exceptions.InvalidSchema): + except httpx.InvalidURL: return 'No report.' -@copycat_api.get('/') -@login_required -@parse_query(GetReportQuery) -def get_report(user, query: GetReportQuery): +@copycat_router.get('') +def get_report(query: GetReportQuery = Depends(), + user=Depends(login_required)): course = query.course problem_id = query.problem_id if not (problem_id and course): return HTTPError( 'missing arguments! (In HTTP GET argument format)', 400, - data={ - 'need': ['course', 'problemId'], - }, + data={'need': ['course', 'problemId']}, ) - # some privilege or exist check try: problem = Problem(int(problem_id)) except ValueError: @@ -178,10 +162,8 @@ def get_report(user, query: GetReportQuery): ) -@copycat_api.post('/') -@login_required -@parse_body(DetectBody) -def detect(user, body: DetectBody): +@copycat_router.post('') +def detect(body: DetectBody, user=Depends(login_required)): course = body.course problem_id = body.problem_id student_nicknames = body.student_nicknames @@ -189,24 +171,19 @@ def detect(user, body: DetectBody): return HTTPError( 'missing arguments! (In Json format)', 400, - data={ - 'need': ['course', 'problemId', 'studentNicknames'], - }, + data={'need': ['course', 'problemId', 'studentNicknames']}, ) course = Course(course) problem = Problem(problem_id) - # Check if student is in course student_dict = {} for student, nickname in student_nicknames.items(): if not User(student): return HTTPResponse(f'User: {student} not found.', 404) student_dict[student] = nickname - # Check student_dict if not student_dict: return HTTPResponse('Empty student list.', 404) - # some privilege or exist check if not course.permission(user, Course.Permission.GRADE): return HTTPError('Forbidden.', 403) if not problem: @@ -215,20 +192,11 @@ def detect(user, body: DetectBody): return HTTPError('Course not found.', 404) problem = Problem(problem_id) - problem.update( - cpp_report_url="", - python_report_url="", - moss_status=1, - ) - if not current_app.config['TESTING']: + problem.update(cpp_report_url="", python_report_url="", moss_status=1) + if not is_testing(): threading.Thread( target=get_report_task, - args=( - user, - problem_id, - student_dict, - ), + args=(user, problem_id, student_dict), ).start() - # return Success return HTTPResponse('Success.') diff --git a/model/course.py b/model/course.py index 45661376..30353f5d 100644 --- a/model/course.py +++ b/model/course.py @@ -1,8 +1,9 @@ from typing import Optional -from flask import Blueprint, request +from fastapi import APIRouter, Depends +from datetime import datetime from mongo import * -from .auth import * +from .auth import identity_verify, login_required from .utils import * from .schemas import ( ModifyCoursesBody, @@ -15,16 +16,14 @@ from mongo.utils import * from mongo.course import * from mongo import engine -from datetime import datetime -__all__ = ['course_api'] +__all__ = ['course_router'] -course_api = Blueprint('course_api', __name__) +course_router = APIRouter() -@course_api.get('/') -@login_required -def get_courses(user): +@course_router.get('') +def get_courses(user=Depends(login_required)): data = [{ 'course': c.course_name, 'teacher': c.teacher.info, @@ -32,41 +31,25 @@ def get_courses(user): return HTTPResponse('Success.', data=data) -@course_api.get('/summary') -@identity_verify(0) -def get_courses_summary(user): +@course_router.get('/summary') +def get_courses_summary(user: User = identity_verify(0)): courses = [Course(c) for c in Course.get_all()] summary = {"courseCount": len(courses), "breakdown": []} - for course in courses: - # The user is admin, it won't filter out any problems (it's required) problems = Problem.get_problem_list(user, course=course.course_name) course_summary = course.get_course_summary(problems) course_summary["problemCount"] = len(problems) summary["breakdown"].append(course_summary) - return HTTPResponse("Success.", data=summary) -@course_api.route('/', methods=['POST', 'PUT', 'DELETE']) -@parse_body(ModifyCoursesBody) -@identity_verify(0, 1) -def modify_courses(user, body: ModifyCoursesBody): - course = body.course - new_course = body.new_course +@course_router.post('') +def create_course(body: ModifyCoursesBody, user: User = identity_verify(0, 1)): teacher = body.teacher - r = None if user.role == 1: teacher = user.username try: - if request.method == 'POST': - r = Course.add_course(course, teacher) - if request.method == 'PUT': - co = Course(course) - co.edit_course(user, new_course, teacher) - if request.method == 'DELETE': - co = Course(course) - co.delete_course(user) + Course.add_course(body.course, teacher) except ValueError: return HTTPError('Not allowed name.', 400) except NotUniqueError: @@ -78,41 +61,69 @@ def modify_courses(user, body: ModifyCoursesBody): return HTTPResponse('Success.') -@course_api.get('/') -@login_required -def get_course(user, course_name): - course = Course(course_name) +@course_router.put('') +def update_course_meta(body: ModifyCoursesBody, + user: User = identity_verify(0, 1)): + teacher = body.teacher + if user.role == 1: + teacher = user.username + try: + co = Course(body.course) + co.edit_course(user, body.new_course, teacher) + except ValueError: + return HTTPError('Not allowed name.', 400) + except NotUniqueError: + return HTTPError('Course exists.', 400) + except PermissionError: + return HTTPError('Forbidden.', 403) + except engine.DoesNotExist as e: + return HTTPError(f'{e} not found.', 404) + return HTTPResponse('Success.') + + +@course_router.delete('') +def delete_course(body: ModifyCoursesBody, user: User = identity_verify(0, 1)): + try: + co = Course(body.course) + co.delete_course(user) + except ValueError: + return HTTPError('Not allowed name.', 400) + except PermissionError: + return HTTPError('Forbidden.', 403) + except engine.DoesNotExist as e: + return HTTPError(f'{e} not found.', 404) + return HTTPResponse('Success.') + +@course_router.get('/{course_name}') +def get_course(course_name: str, user=Depends(login_required)): + course = Course(course_name) if not course: return HTTPError('Course not found.', 404) - if not course.permission(user, Course.Permission.VIEW): return HTTPError('You are not in this course.', 403) - return HTTPResponse( 'Success.', data={ "teacher": course.teacher.info, "TAs": [ta.info for ta in course.tas], - "students": [User(name).info for name in course.student_nicknames] + "students": [User(name).info for name in course.student_nicknames], }, ) -@course_api.put('/') -@login_required -@parse_body(UpdateCourseBody) -def update_course(user, course_name, body: UpdateCourseBody): +@course_router.put('/{course_name}') +def update_course(course_name: str, + body: UpdateCourseBody, + user=Depends(login_required)): TAs = body.TAs student_nicknames = body.student_nicknames course = Course(course_name) if not course: return HTTPError('Course not found.', 404) - if not course.permission(user, Course.Permission.VIEW): return HTTPError('You are not in this course.', 403) - if not course.permission(user, Course.Permission.MODIFY): return HTTPError('Forbidden.', 403) @@ -136,11 +147,9 @@ def update_course(user, course_name, body: UpdateCourseBody): return HTTPResponse('Success.') -@course_api.get('//grade/') -@login_required -def get_grade(user, course_name, student): +@course_router.get('/{course_name}/grade/{student}') +def get_grade(course_name: str, student: str, user=Depends(login_required)): course = Course(course_name) - if not course: return HTTPError('Course not found.', 404) if not course.permission(user, Course.Permission.VIEW): @@ -150,25 +159,25 @@ def get_grade(user, course_name, student): if course.permission(user, Course.Permission.SCORE) and user.username != student: return HTTPError('You can only view your score.', 403) + return HTTPResponse( + 'Success.', + data=[{ + 'title': score['title'], + 'content': score['content'], + 'score': score['score'], + 'timestamp': score['timestamp'].timestamp(), + } for score in course.student_scores.get(student, [])], + ) - return HTTPResponse('Success.', - data=[{ - 'title': score['title'], - 'content': score['content'], - 'score': score['score'], - 'timestamp': score['timestamp'].timestamp() - } for score in course.student_scores.get(student, [])]) - - -@course_api.post('//grade/') -@login_required -@parse_body(AddGradeBody) -def add_grade(user, course_name, student, body: AddGradeBody): - title = body.title - content = body.content - score = body.score - course = Course(course_name) +@course_router.post('/{course_name}/grade/{student}') +def add_grade( + course_name: str, + student: str, + body: AddGradeBody, + user=Depends(login_required), +): + course = Course(course_name) if not course: return HTTPError('Course not found.', 404) if not course.permission(user, Course.Permission.VIEW): @@ -179,29 +188,27 @@ def add_grade(user, course_name, student, body: AddGradeBody): return HTTPError('You can only view your score.', 403) score_list = course.student_scores.get(student, []) - if title in [score['title'] for score in score_list]: + if body.title in [s['title'] for s in score_list]: return HTTPError('This title is taken.', 400) score_list.append({ - 'title': title, - 'content': content, - 'score': score, - 'timestamp': datetime.now() + 'title': body.title, + 'content': body.content, + 'score': body.score, + 'timestamp': datetime.now(), }) course.student_scores[student] = score_list course.save() return HTTPResponse('Success.') -@course_api.put('//grade/') -@login_required -@parse_body(UpdateGradeBody) -def update_grade(user, course_name, student, body: UpdateGradeBody): - title = body.title - new_title = body.new_title - content = body.content - score = body.score +@course_router.put('/{course_name}/grade/{student}') +def update_grade( + course_name: str, + student: str, + body: UpdateGradeBody, + user=Depends(login_required), +): course = Course(course_name) - if not course: return HTTPError('Course not found.', 404) if not course.permission(user, Course.Permission.VIEW): @@ -212,32 +219,32 @@ def update_grade(user, course_name, student, body: UpdateGradeBody): return HTTPError('You can only view your score.', 403) score_list = course.student_scores.get(student, []) - title_list = [score['title'] for score in score_list] - if title not in title_list: + title_list = [s['title'] for s in score_list] + if body.title not in title_list: return HTTPError('Score not found.', 404) - index = title_list.index(title) - if new_title is not None: - if new_title in title_list: - return HTTPError('This title is taken.', 400) - title = new_title + index = title_list.index(body.title) + title = body.new_title if body.new_title is not None else body.title + if body.new_title is not None and body.new_title in title_list: + return HTTPError('This title is taken.', 400) score_list[index] = { 'title': title, - 'content': content, - 'score': score, - 'timestamp': datetime.now() + 'content': body.content, + 'score': body.score, + 'timestamp': datetime.now(), } course.student_scores[student] = score_list course.save() return HTTPResponse('Success.') -@course_api.delete('//grade/') -@login_required -@parse_body(DeleteGradeBody) -def delete_grade(user, course_name, student, body: DeleteGradeBody): - title = body.title +@course_router.delete('/{course_name}/grade/{student}') +def delete_grade( + course_name: str, + student: str, + body: DeleteGradeBody, + user=Depends(login_required), +): course = Course(course_name) - if not course: return HTTPError('Course not found.', 404) if not course.permission(user, Course.Permission.VIEW): @@ -248,48 +255,45 @@ def delete_grade(user, course_name, student, body: DeleteGradeBody): return HTTPError('You can only view your score.', 403) score_list = course.student_scores.get(student, []) - title_list = [score['title'] for score in score_list] - if title not in title_list: + title_list = [s['title'] for s in score_list] + if body.title not in title_list: return HTTPError('Score not found.', 404) - index = title_list.index(title) + index = title_list.index(body.title) del score_list[index] course.student_scores[student] = score_list course.save() return HTTPResponse('Success.') -@course_api.get('//scoreboard') -@login_required -@parse_query(GetCourseScoreboardQuery) -@Request.doc('course_name', 'course', Course) -def get_course_scoreboard(user, query: GetCourseScoreboardQuery, - course: Course): +@course_router.get('/{course_name}/scoreboard') +def get_course_scoreboard( + course_name: str, + query: GetCourseScoreboardQuery = Depends(), + user=Depends(login_required), + course: Course = get_doc('course_name', Course), +): pids = query.pids start = query.start end = query.end try: pids = pids.split(',') pids = [int(pid.strip()) for pid in pids] - except: + except Exception: return HTTPError('Error occurred when parsing `pids`.', 400) if start: try: start = float(start) - except: + except Exception: return HTTPError('Type of `start` should be float.', 400) if end: try: end = float(end) - except: + except Exception: return HTTPError('Type of `end` should be float.', 400) if not course.permission(user, Course.Permission.GRADE): return HTTPError('Permission denied', 403) ret = course.get_scoreboard(pids, start, end) - - return HTTPResponse( - 'Success.', - data=ret, - ) + return HTTPResponse('Success.', data=ret) diff --git a/model/health.py b/model/health.py index 06d509f2..1a68d8a7 100644 --- a/model/health.py +++ b/model/health.py @@ -1,16 +1,16 @@ -from flask import Blueprint +from fastapi import APIRouter from pymongo import MongoClient from .utils import * from mongo.utils import RedisCache from mongo.engine import MONGO_HOST -__all__ = ('health_api', ) +__all__ = ('health_router', ) -health_api = Blueprint('health_api', __name__) +health_router = APIRouter() -@health_api.route('/') +@health_router.get('') def health(): # Check mongo client = MongoClient(MONGO_HOST) @@ -26,6 +26,6 @@ def health(): status_code=500, data={ 'mongo': mongo_ok, - 'redis': redis_ok + 'redis': redis_ok, }, ) diff --git a/model/homework.py b/model/homework.py index d37beae1..432e5afa 100644 --- a/model/homework.py +++ b/model/homework.py @@ -1,22 +1,20 @@ -from flask import Blueprint +from fastapi import APIRouter, Depends from mongo import * from mongo import engine from .utils import * from .auth import login_required -from .course import course_api +from .course import course_router from .schemas import CreateHomeworkBody, UpdateHomeworkBody, PatchIpFiltersBody -__all__ = ['homework_api'] +__all__ = ['homework_router'] -homework_api = Blueprint('homework_api', __name__) +homework_router = APIRouter() -@homework_api.post('/') -@login_required -@parse_body(CreateHomeworkBody) -def create_homework(user, body: CreateHomeworkBody): +@homework_router.post('') +def create_homework(body: CreateHomeworkBody, user=Depends(login_required)): try: - homework = Homework.add( + Homework.add( user=user, hw_name=body.name, markdown=body.markdown, @@ -38,9 +36,8 @@ def create_homework(user, body: CreateHomeworkBody): return HTTPResponse('Add homework Success') -@homework_api.get('/') -@login_required -def get_homework(user, homework_id): +@homework_router.get('/{homework_id}') +def get_homework(homework_id: str, user=Depends(login_required)): try: homework = Homework.get_by_id(homework_id) ret = { @@ -54,9 +51,8 @@ def get_homework(user, homework_id): homework.problem_ids, 'markdown': homework.markdown, - 'studentStatus': - homework.student_status - if user.role < 2 else homework.student_status.get(user.username), + 'studentStatus': (homework.student_status if user.role < 2 else + homework.student_status.get(user.username)), 'penalty': homework.penalty if hasattr(homework, 'penalty') else None, } @@ -67,12 +63,12 @@ def get_homework(user, homework_id): return HTTPResponse('get homework', data=ret) -@homework_api.put('/') -@login_required -@parse_body(UpdateHomeworkBody) -def update_homework(user, homework_id, body: UpdateHomeworkBody): +@homework_router.put('/{homework_id}') +def update_homework(homework_id: str, + body: UpdateHomeworkBody, + user=Depends(login_required)): try: - homework = Homework.update( + Homework.update( user=user, homework_id=homework_id, markdown=body.markdown, @@ -90,13 +86,11 @@ def update_homework(user, homework_id, body: UpdateHomeworkBody): return HTTPResponse('Update homework Success') -@homework_api.delete('/') -@login_required -def delete_homework(user, homework_id): +@homework_router.delete('/{homework_id}') +def delete_homework(homework_id: str, user=Depends(login_required)): try: homework = Homework(homework_id) - homework = homework.delete_problems(user=user, - course=homework.course_id) + homework.delete_problems(user=user, course=homework.course_id) except engine.DoesNotExist as e: return HTTPError(str(e), 404) except (PermissionError, engine.NotUniqueError) as e: @@ -104,12 +98,9 @@ def delete_homework(user, homework_id): return HTTPResponse('Delete homework Success') -@course_api.route('//homework', methods=['GET']) -@login_required -def get_homework_list(user, course_name): - ''' - get a list of homework - ''' +@course_router.get('/{course_name}/homework') +def get_homework_list(course_name: str, user=Depends(login_required)): + '''Get a list of homework for a course.''' try: homeworks = Homework.get_homeworks(course_name=course_name) data = [] @@ -120,16 +111,13 @@ def get_homework_list(user, course_name): 'end': int(homework.duration.end.timestamp()), 'problemIds': homework.problem_ids, 'markdown': homework.markdown, - 'id': str(homework.id) + 'id': str(homework.id), } - # normal user can not view other's status if user.role < 2: - new.update({'studentStatus': homework.student_status}) + new['studentStatus'] = homework.student_status else: - new.update({ - 'studentStatus': - homework.student_status.get(user.username) - }) + new['studentStatus'] = homework.student_status.get( + user.username) data.append(new) except DoesNotExist: return HTTPError('course not exists', @@ -138,13 +126,10 @@ def get_homework_list(user, course_name): return HTTPResponse('get homeworks', data=data) -@homework_api.route('///ip-filters', methods=['GET']) -@login_required -def get_ip_filters( - user, - course: str, - homework_name: str, -): +@homework_router.get('/{course}/{homework_name}/ip-filters') +def get_ip_filters(course: str, + homework_name: str, + user=Depends(login_required)): if user.role != 0: return HTTPError('Not admin!', 403) try: @@ -154,14 +139,12 @@ def get_ip_filters( return HTTPResponse(data={'ipFilters': hw.ip_filters}) -@homework_api.route('///ip-filters', methods=['PATCH']) -@login_required -@parse_body(PatchIpFiltersBody) +@homework_router.patch('/{course}/{homework_name}/ip-filters') def patch_ip_filters( - user, - course: str, - homework_name: str, - body: PatchIpFiltersBody, + course: str, + homework_name: str, + body: PatchIpFiltersBody, + user=Depends(login_required), ): if user.role != 0: return HTTPError('Not admin!', 403) @@ -182,12 +165,10 @@ def patch_ip_filters( adds.append(value) else: dels.append(value) - # Validate filter format try: IPFilter(value) except ValueError as e: return HTTPError(str(e), 400) - hw.update(push_all__ip_filters=adds) hw.update(pull_all__ip_filters=dels) return HTTPResponse() diff --git a/model/post.py b/model/post.py index 5e0ba4c5..2676b0bc 100644 --- a/model/post.py +++ b/model/post.py @@ -1,21 +1,20 @@ -from flask import Blueprint, request +from fastapi import APIRouter, Depends from mongo import * from mongo import engine -from .auth import * +from .auth import login_required from .utils import * from .schemas import ModifyPostBody from mongo.utils import * from mongo.post import * from mongo.course import * -__all__ = ['post_api'] +__all__ = ['post_router'] -post_api = Blueprint('post_api', __name__) +post_router = APIRouter() -@post_api.route('/', methods=['GET']) -@login_required -def get_post(user, course): +@post_router.get('/{course}') +def get_post(course: str, user=Depends(login_required)): target_course = Course(course) if not target_course: return HTTPError("Course not found.", 404) @@ -25,9 +24,10 @@ def get_post(user, course): return HTTPResponse('Success.', data=data) -@post_api.route('/view//', methods=['GET']) -@login_required -def get_single_post(user, course, target_thread_id): +@post_router.get('/view/{course}/{target_thread_id}') +def get_single_post(course: str, + target_thread_id: str, + user=Depends(login_required)): target_course = Course(course) if not target_course: return HTTPError("Course not found.", 404) @@ -37,64 +37,98 @@ def get_single_post(user, course, target_thread_id): return HTTPResponse('Success.', data=data) -@post_api.route('/', methods=['POST', 'PUT', 'DELETE']) -@parse_body(ModifyPostBody) -@login_required -def modify_post(user, body: ModifyPostBody): +def _resolve_post_target(body: ModifyPostBody, user): + """Shared logic to resolve course/thread context and check permissions.""" course = body.course - title = body.title - content = body.content target_thread_id = body.target_thread_id + if course == 'Public': - return HTTPError('You can not add post in system.', 403) + return HTTPError('You can not add post in system.', + 403), None, None, None if course and target_thread_id: return HTTPError( - 'Request is fail,course or target_thread_id must be none.', 400) - elif course: + 'Request is fail,course or target_thread_id must be none.', + 400), None, None, None + + if course: course_obj = Course(course) if not course_obj: - return HTTPError('Course not exist.', 404) + return HTTPError('Course not exist.', 404), None, None, None target_course = course_obj + target_thread = None elif target_thread_id: try: target_thread = engine.PostThread.objects.get(id=target_thread_id) except engine.DoesNotExist: - try: # to protect input post id + try: target_post = engine.Post.objects.get(id=target_thread_id) except engine.DoesNotExist: - return HTTPError('Post/reply not exist.', 404) + return HTTPError('Post/reply not exist.', + 404), None, None, None target_thread = target_post.thread target_thread_id = target_thread.id - if target_thread.status: # 1 is deleted - return HTTPResponse('Forbidden,the post/reply is deleted.', 403) + if target_thread.status: + return HTTPResponse('Forbidden,the post/reply is deleted.', + 403), None, None, None target_course = Course(target_thread.course_id) else: return HTTPError( - 'Request is fail,course and target_thread_id are both none.', 400) + 'Request is fail,course and target_thread_id are both none.', + 400), None, None, None capability = target_course.own_permission(user) if not (capability & Course.Permission.VIEW): - return HTTPError('You are not in this course.', 403) - if request.method == 'POST': - # add reply - if course: - r = Post.add_post(course, user, content, title) - # add course post - elif target_thread_id: - r = Post.add_reply(target_thread, user, content) - if request.method == 'PUT': - if course: - return HTTPError( - "Request is fail,you should provide target_thread_id replace course.", - 400) - r = Post.edit_post(target_thread, user, content, title, capability) - if request.method == 'DELETE': - if course: - return HTTPError( - "Request is fail,you should provide target_thread_id replace course.", - 400) - r = Post.delete_post(target_thread, user, capability) + return HTTPError('You are not in this course.', 403), None, None, None + + return None, target_course, target_thread, capability + + +@post_router.post('') +def add_post(body: ModifyPostBody, user=Depends(login_required)): + err, target_course, target_thread, capability = _resolve_post_target( + body, user) + if err is not None: + return err + course = body.course + target_thread_id = body.target_thread_id + if course: + r = Post.add_post(course, user, body.content, body.title) + else: + r = Post.add_reply(target_thread, user, body.content) + if r is not None: + return HTTPError(r, 403) + return HTTPResponse('success.') + + +@post_router.put('') +def edit_post(body: ModifyPostBody, user=Depends(login_required)): + err, target_course, target_thread, capability = _resolve_post_target( + body, user) + if err is not None: + return err + if body.course: + return HTTPError( + "Request is fail,you should provide target_thread_id replace course.", + 400) + r = Post.edit_post(target_thread, user, body.content, body.title, + capability) + if r is not None: + return HTTPError(r, 403) + return HTTPResponse('success.') + + +@post_router.delete('') +def delete_post(body: ModifyPostBody, user=Depends(login_required)): + err, target_course, target_thread, capability = _resolve_post_target( + body, user) + if err is not None: + return err + if body.course: + return HTTPError( + "Request is fail,you should provide target_thread_id replace course.", + 400) + r = Post.delete_post(target_thread, user, capability) if r is not None: return HTTPError(r, 403) return HTTPResponse('success.') diff --git a/model/problem.py b/model/problem.py index 0d873da5..6528f266 100644 --- a/model/problem.py +++ b/model/problem.py @@ -1,8 +1,11 @@ +import asyncio import json import hashlib import statistics from dataclasses import asdict -from flask import Blueprint, request, send_file +from typing import Optional +from fastapi import APIRouter, Depends, Request, UploadFile, File +from fastapi.responses import StreamingResponse from urllib import parse from zipfile import BadZipFile from mongo import * @@ -10,7 +13,7 @@ from mongo import sandbox from mongo.utils import drop_none from mongo.problem import * -from .auth import * +from .auth import identity_verify, login_required from .utils import * from .schemas import ( ViewProblemListQuery, @@ -22,9 +25,9 @@ PublishProblemBody, ) -__all__ = ['problem_api'] +__all__ = ['problem_router'] -problem_api = Blueprint('problem_api', __name__) +problem_router = APIRouter() def permission_error_response(): @@ -35,27 +38,24 @@ def online_error_response(): return HTTPError('Problem is unavailable', 403) -@problem_api.get('/') -@login_required -@parse_query(ViewProblemListQuery) -def view_problem_list(user, query: ViewProblemListQuery): +@problem_router.get('') +def view_problem_list( + query: ViewProblemListQuery = Depends(), + user=Depends(login_required), +): offset = query.offset count = query.count tags = query.tags problem_id = query.problem_id name = query.name course = query.course - # casting args try: if offset is not None: offset = int(offset) if count is not None: count = int(count) except (TypeError, ValueError): - return HTTPError( - 'offset and count must be integer!', - 400, - ) + return HTTPError('offset and count must be integer!', 400) problem_id, name, tags, course = (parse.unquote(p or '') or None for p in (problem_id, name, tags, course)) @@ -82,53 +82,16 @@ def view_problem_list(user, query: ViewProblemListQuery): 'tags': p.tags, 'type': p.problem_type, 'quota': p.quota, - 'submitCount': Problem(p.problem_id).submit_count(user) + 'submitCount': Problem(p.problem_id).submit_count(user), } for p in data] return HTTPResponse('Success.', data=data) -@problem_api.route('/', methods=['GET']) -@problem_api.route('/view/', methods=['GET']) -@login_required -@Request.doc('problem_id', 'problem', Problem) -def view_problem(user: User, problem: Problem): - if not problem.permission(user=user, req=problem.Permission.VIEW): - return permission_error_response() - if not problem.permission(user=user, req=problem.Permission.ONLINE): - return online_error_response() - # ip validation - if not problem.is_valid_ip(get_ip()): - return HTTPError('Invalid IP address.', 403) - # filter data - data = problem.detailed_info( - 'problemName', - 'description', - 'owner', - 'tags', - 'allowedLanguage', - 'courses', - 'quota', - defaultCode='defaultCode', - status='problemStatus', - type='problemType', - testCase='testCase__tasks', - ) - if problem.obj.problem_type == 1: - data.update({'fillInTemplate': problem.obj.test_case.fill_in_template}) - data.update({ - 'submitCount': problem.submit_count(user), - 'highScore': problem.get_high_score(user=user), - }) - return HTTPResponse('Problem can view.', data=data) - - -@problem_api.route('/manage/', methods=['GET']) -@Request.doc('problem_id', 'problem', Problem) -@identity_verify(0, 1) # admin and teacher only -def get_problem_detailed(user, problem: Problem): - ''' - Get problem's detailed information - ''' +@problem_router.get('/manage/{problem_id}') +def get_problem_detailed( + user: User = identity_verify(0, 1), + problem: Problem = get_doc('problem_id', Problem, int), +): if not problem.permission(user, problem.Permission.MANAGE): return permission_error_response() if not problem.permission(user=user, req=problem.Permission.ONLINE): @@ -147,36 +110,30 @@ def get_problem_detailed(user, problem: Problem): status='problemStatus', type='problemType', ) - info.update({'submitCount': problem.submit_count(user)}) - return HTTPResponse( - 'Success.', - data=info, - ) + info['submitCount'] = problem.submit_count(user) + return HTTPResponse('Success.', data=info) -@problem_api.post('/manage') -@identity_verify(0, 1) -@parse_body(ProblemBody) -def create_problem(user: User, body: ProblemBody): +@problem_router.post('/manage') +def create_problem(body: ProblemBody, user: User = identity_verify(0, 1)): try: pid = Problem.add(user=user, **body.model_dump()) except ValidationError as e: - return HTTPError( - 'Invalid or missing arguments.', - 400, - data=e.to_dict(), - ) - except DoesNotExist as e: + return HTTPError('Invalid or missing arguments.', + 400, + data=e.to_dict()) + except DoesNotExist: return HTTPError('Course not found', 404) except ValueError as e: return HTTPError(str(e), 400) return HTTPResponse(data={'problemId': pid}) -@problem_api.route('/manage/', methods=['DELETE']) -@identity_verify(0, 1) -@Request.doc('problem', Problem) -def delete_problem(user: User, problem: Problem): +@problem_router.delete('/manage/{problem_id}') +def delete_problem( + user: User = identity_verify(0, 1), + problem: Problem = get_doc('problem_id', Problem, int), +): if not problem.permission(user, problem.Permission.MANAGE): return permission_error_response() if not problem.permission(user=user, req=problem.Permission.ONLINE): @@ -185,24 +142,50 @@ def delete_problem(user: User, problem: Problem): return HTTPResponse() -@problem_api.put('/manage/') -@identity_verify(0, 1) -@Request.doc('problem', Problem) -def manage_problem(user: User, problem: Problem): +@problem_router.put('/manage/{problem_id}') +async def manage_problem( + problem_id: int, + request: Request, + user: User = identity_verify(0, 1), +): + problem = await asyncio.to_thread(Problem, problem_id) + if not problem: + raise NOJException('Problem not found', 404) + if not await asyncio.to_thread(problem.permission, user, problem.Permission.MANAGE): + return permission_error_response() + if not await asyncio.to_thread(problem.permission, user=user, req=problem.Permission.ONLINE): + return online_error_response() - @parse_body(ProblemBody) - def modify_problem(body: ProblemBody): - Problem.edit_problem( - user=user, - problem_id=problem.id, - **drop_none(body.model_dump()), - ) + content_type = request.headers.get('content-type', '') + if content_type.startswith('application/json'): + body_data = await request.json() + try: + body = ProblemBody(**body_data) + except Exception: + return HTTPError('Invalid or missing arguments.', 400) + try: + await asyncio.to_thread( + Problem.edit_problem, + user=user, + problem_id=problem.id, + **drop_none(body.model_dump()), + ) + except ValidationError as ve: + return HTTPError('Invalid or missing arguments.', + 400, + data=ve.to_dict()) + except engine.DoesNotExist: + return HTTPError('Course not found.', 404) return HTTPResponse() - - @Request.files('case') - def modify_problem_test_case(case): + elif content_type.startswith('multipart/form-data'): + from io import BytesIO + form = await request.form() + case_upload = form.get('case') + if case_upload is None or not hasattr(case_upload, 'read'): + return HTTPError('missing or invalid form field: case', 400) + case = BytesIO(await case_upload.read()) try: - problem.update_test_case(case) + await asyncio.to_thread(problem.update_test_case, case) except engine.DoesNotExist as e: return HTTPError(str(e), 404) except (ValueError, BadZipFile) as e: @@ -210,115 +193,90 @@ def modify_problem_test_case(case): except BadTestCase as e: return HTTPError(str(e), 400) return HTTPResponse('Success.') - - if not problem.permission(user, problem.Permission.MANAGE): - return permission_error_response() - if not problem.permission(user=user, req=problem.Permission.ONLINE): - return online_error_response() - - # edit problem - try: - # modify problem meta - if request.content_type.startswith('application/json'): - return modify_problem() - # upload testcase file - elif request.content_type.startswith('multipart/form-data'): - return modify_problem_test_case() - else: - return HTTPError( - 'Unknown content type', - 400, - data={'contentType': request.content_type}, - ) - except ValidationError as ve: - return HTTPError( - 'Invalid or missing arguments.', - 400, - data=ve.to_dict(), - ) - except engine.DoesNotExist: - return HTTPError('Course not found.', 404) - - -@problem_api.post('//initiate-test-case-upload') -@identity_verify(0, 1) -@Request.doc('problem', Problem) -@parse_body(InitiateTestCaseUploadBody) -def initiate_test_case_upload(user: User, problem: Problem, - body: InitiateTestCaseUploadBody): - length = body.length - part_size = body.part_size + else: + return HTTPError('Unknown content type', + 400, + data={'contentType': content_type}) + + +@problem_router.post('/{problem_id}/initiate-test-case-upload') +def initiate_test_case_upload( + body: InitiateTestCaseUploadBody, + user: User = identity_verify(0, 1), + problem: Problem = get_doc('problem_id', Problem, int), +): if not problem.permission(user, problem.Permission.MANAGE): return permission_error_response() if not problem.permission(user=user, req=problem.Permission.ONLINE): return online_error_response() upload_info = problem.generate_urls_for_uploading_test_case( - length, part_size) + body.length, body.part_size) return HTTPResponse(data=asdict(upload_info)) -@problem_api.post('//complete-test-case-upload') -@identity_verify(0, 1) -@Request.doc('problem', Problem) -@parse_body(CompleteTestCaseUploadBody) -def complete_test_case_upload(user: User, problem: Problem, - body: CompleteTestCaseUploadBody): +@problem_router.post('/{problem_id}/complete-test-case-upload') +def complete_test_case_upload( + body: CompleteTestCaseUploadBody, + user: User = identity_verify(0, 1), + problem: Problem = get_doc('problem_id', Problem, int), +): if not problem.permission(user, problem.Permission.MANAGE): return permission_error_response() if not problem.permission(user=user, req=problem.Permission.ONLINE): return online_error_response() - upload_id = body.upload_id - # convert parts to list[Part] from minio.datatypes import Part parts = [ Part(part_number=part['PartNumber'], etag=part['ETag']) for part in body.parts ] try: - problem.complete_test_case_upload(upload_id, parts) + problem.complete_test_case_upload(body.upload_id, parts) except BadTestCase as e: return HTTPError(str(e), 400) return HTTPResponse(status_code=201) -@problem_api.route('//test-case', methods=['GET']) -@problem_api.route('//testcase', methods=['GET']) -@login_required -@Request.doc('problem_id', 'problem', Problem) -def get_test_case(user: User, problem: Problem): +@problem_router.get('/{problem_id}/test-case') +@problem_router.get('/{problem_id}/testcase') +def get_test_case( + problem_id: int, + user=Depends(login_required), + problem: Problem = get_doc('problem_id', Problem, int), +): if not problem.permission(user, problem.Permission.MANAGE): return permission_error_response() if not problem.permission(user=user, req=problem.Permission.ONLINE): return online_error_response() - return send_file( + return StreamingResponse( problem.get_test_case(), - mimetype='application/zip', - as_attachment=True, - download_name=f'testdata-{problem.id}.zip', + media_type='application/zip', + headers={ + 'Content-Disposition': + f'attachment; filename="testdata-{problem.id}.zip"' + }, ) -# FIXME: Find a better name -@problem_api.get('//testdata') -@parse_query(GetTestdataQuery) -@Request.doc('problem_id', 'problem', Problem) -def get_testdata(query: GetTestdataQuery, problem: Problem): - token = query.token - if sandbox.find_by_token(token) is None: +@problem_router.get('/{problem_id}/testdata') +def get_testdata( + query: GetTestdataQuery = Depends(), + problem: Problem = get_doc('problem_id', Problem, int), +): + if sandbox.find_by_token(query.token) is None: return HTTPError('Invalid sandbox token', 401) - return send_file( + return StreamingResponse( problem.get_test_case(), - mimetype='application/zip', - as_attachment=True, - download_name=f'testdata-{problem.id}.zip', + media_type='application/zip', + headers={ + 'Content-Disposition': + f'attachment; filename="testdata-{problem.id}.zip"' + }, ) -@problem_api.get('//checksum') -@parse_query(GetTestdataQuery) -def get_checksum(query: GetTestdataQuery, problem_id: int): - token = query.token - if sandbox.find_by_token(token) is None: +@problem_router.get('/{problem_id}/checksum') +def get_checksum(problem_id: int, query: GetTestdataQuery = Depends()): + if sandbox.find_by_token(query.token) is None: return HTTPError('Invalid sandbox token', 401) problem = Problem(problem_id) if not problem: @@ -327,17 +285,14 @@ def get_checksum(query: GetTestdataQuery, problem_id: int): 'tasks': [json.loads(task.to_json()) for task in problem.test_case.tasks] }).encode() - # TODO: use etag of bucket object content = problem.get_test_case().read() + meta digest = hashlib.md5(content).hexdigest() return HTTPResponse(data=digest) -@problem_api.get('//meta') -@parse_query(GetTestdataQuery) -def get_meta(query: GetTestdataQuery, problem_id: int): - token = query.token - if sandbox.find_by_token(token) is None: +@problem_router.get('/{problem_id}/meta') +def get_meta(problem_id: int, query: GetTestdataQuery = Depends()): + if sandbox.find_by_token(query.token) is None: return HTTPError('Invalid sandbox token', 401) problem = Problem(problem_id) if not problem: @@ -349,48 +304,37 @@ def get_meta(query: GetTestdataQuery, problem_id: int): return HTTPResponse(data=meta) -@problem_api.route('//high-score', methods=['GET']) -@login_required -@Request.doc('problem_id', 'problem', Problem) -def high_score(user: User, problem: Problem): - return HTTPResponse(data={ - 'score': problem.get_high_score(user=user), - }) +@problem_router.get('/{problem_id}/high-score') +def high_score( + user=Depends(login_required), + problem: Problem = get_doc('problem_id', Problem, int), +): + return HTTPResponse(data={'score': problem.get_high_score(user=user)}) -@problem_api.post('/clone') -@problem_api.post('/copy') -@identity_verify(0, 1) -@parse_body(CloneProblemBody) -def clone_problem(user: User, body: CloneProblemBody): +@problem_router.post('/clone') +@problem_router.post('/copy') +def clone_problem(body: CloneProblemBody, user: User = identity_verify(0, 1)): try: problem = Problem(body.problem_id) if not problem: - return HTTPError(f'Problem not found', 404) + return HTTPError('Problem not found', 404) except engine.DoesNotExist as e: return HTTPError(str(e), 404) if not problem.permission(user, problem.Permission.VIEW): return HTTPError('Problem can not view.', 403) override = drop_none({'status': body.status}) - new_problem_id = problem.copy_to( - user=user, - target=body.target, - **override, - ) - return HTTPResponse( - 'Success.', - data={'problemId': new_problem_id}, - ) + new_problem_id = problem.copy_to(user=user, target=body.target, **override) + return HTTPResponse('Success.', data={'problemId': new_problem_id}) -@problem_api.post('/publish') -@identity_verify(0, 1) -@parse_body(PublishProblemBody) -def publish_problem(user, body: PublishProblemBody): +@problem_router.post('/publish') +def publish_problem(body: PublishProblemBody, + user: User = identity_verify(0, 1)): try: problem = Problem(body.problem_id) if not problem: - return HTTPError(f'Problem not found', 404) + return HTTPError('Problem not found', 404) except engine.DoesNotExist as e: return HTTPError(str(e), 404) if user.role == 1 and problem.owner != user.username: @@ -399,10 +343,11 @@ def publish_problem(user, body: PublishProblemBody): return HTTPResponse('Success.') -@problem_api.route('//stats', methods=['GET']) -@login_required -@Request.doc('problem_id', 'problem', Problem) -def problem_stats(user: User, problem: Problem): +@problem_router.get('/{problem_id}/stats') +def problem_stats( + user=Depends(login_required), + problem: Problem = get_doc('problem_id', Problem, int), +): if not problem.permission(user, problem.Permission.VIEW): return permission_error_response() if not problem.permission(user=user, req=problem.Permission.ONLINE): @@ -412,7 +357,6 @@ def problem_stats(user: User, problem: Problem): for course in problem.courses: students += [User(name) for name in course.student_nicknames.keys()] students_high_scores = [problem.get_high_score(user=u) for u in students] - # These score statistics are only counting the scores of the students in the course. ret['acUserRatio'] = [problem.get_ac_user_count(), len(students)] ret['triedUserCount'] = problem.get_tried_user_count() ret['average'] = None if len(students) == 0 else statistics.mean( @@ -420,31 +364,64 @@ def problem_stats(user: User, problem: Problem): ret['std'] = None if len(students) <= 1 else statistics.pstdev( students_high_scores) ret['scoreDistribution'] = students_high_scores - # However, submissions include the submissions of teacher and admin. ret['statusCount'] = problem.get_submission_status() params = { 'user': user, 'offset': 0, 'count': 10, 'problem': problem.id, - 'status': 0, + 'status': 0 } - top_10_runtime_submissions = [ + ret['top10RunTime'] = [ s.to_dict() for s in Submission.filter(**params, sort_by='runTime') ] - ret['top10RunTime'] = top_10_runtime_submissions - top_10_memory_submissions = [ + ret['top10MemoryUsage'] = [ s.to_dict() for s in Submission.filter(**params, sort_by='memoryUsage') ] - ret['top10MemoryUsage'] = top_10_memory_submissions return HTTPResponse('Success.', data=ret) -@problem_api.post('//migrate-test-case') -@login_required -@identity_verify(0) # admin only -@Request.doc('problem_id', 'problem', Problem) -def problem_migrate_test_case(user: User, problem: Problem): +@problem_router.get('/{problem_id}') +@problem_router.get('/view/{problem_id}') +def view_problem( + problem_id: int, + ip: str = Depends(get_ip), + user=Depends(login_required), + problem: Problem = get_doc('problem_id', Problem, int), +): + if not problem.permission(user=user, req=problem.Permission.VIEW): + return permission_error_response() + if not problem.permission(user=user, req=problem.Permission.ONLINE): + return online_error_response() + if not problem.is_valid_ip(ip): + return HTTPError('Invalid IP address.', 403) + data = problem.detailed_info( + 'problemName', + 'description', + 'owner', + 'tags', + 'allowedLanguage', + 'courses', + 'quota', + defaultCode='defaultCode', + status='problemStatus', + type='problemType', + testCase='testCase__tasks', + ) + if problem.obj.problem_type == 1: + data['fillInTemplate'] = problem.obj.test_case.fill_in_template + data.update({ + 'submitCount': problem.submit_count(user), + 'highScore': problem.get_high_score(user=user), + }) + return HTTPResponse('Problem can view.', data=data) + + +@problem_router.post('/{problem_id}/migrate-test-case') +def problem_migrate_test_case( + user: User = identity_verify(0), + problem: Problem = get_doc('problem_id', Problem, int), +): if not problem.permission(user, problem.Permission.MANAGE): return permission_error_response() problem.migrate_gridfs_to_minio() diff --git a/model/profile.py b/model/profile.py index e4d79fba..ef5d4f5d 100644 --- a/model/profile.py +++ b/model/profile.py @@ -1,19 +1,19 @@ -from flask import Blueprint +from fastapi import APIRouter, Depends +from typing import Optional from mongo import * -from .auth import * +from .auth import login_required from .utils import * from .schemas import EditProfileBody, EditConfigBody -__all__ = ['profile_api'] +__all__ = ['profile_router'] -profile_api = Blueprint('profile_api', __name__) +profile_router = APIRouter() -@profile_api.route('/', methods=['GET']) -@profile_api.route('/', methods=['GET']) -@login_required -def view_profile(user, username=None): +@profile_router.get('') +@profile_router.get('/{username}') +def view_profile(user=Depends(login_required), username: Optional[str] = None): user = user if username is None else User(username) if not user: return HTTPError('Profile not exist.', 404) @@ -21,17 +21,15 @@ def view_profile(user, username=None): data = { 'email': user.obj.email, 'displayedName': user.obj.profile.displayed_name, - 'bio': user.obj.profile.bio + 'bio': user.obj.profile.bio, } data.update(user.info) return HTTPResponse('Profile exist.', data=data) -@profile_api.post('/') -@login_required -@parse_body(EditProfileBody) -def edit_profile(user, body: EditProfileBody): +@profile_router.post('') +def edit_profile(body: EditProfileBody, user=Depends(login_required)): profile = user.obj.profile or {} displayed_name = body.displayed_name bio = body.bio @@ -48,17 +46,15 @@ def edit_profile(user, body: EditProfileBody): return HTTPResponse('Uploaded.', cookies=cookies) -@profile_api.put('/config') -@login_required -@parse_body(EditConfigBody) -def edit_config(user, body: EditConfigBody): +@profile_router.put('/config') +def edit_config(body: EditConfigBody, user=Depends(login_required)): try: config = { 'font_size': body.font_size, 'theme': body.theme, 'indent_type': body.indent_type, 'tab_size': body.tab_size, - 'language': body.language + 'language': body.language, } user.obj.update(editor_config=config) except ValidationError as ve: diff --git a/model/ranking.py b/model/ranking.py index a328034c..9714c71f 100644 --- a/model/ranking.py +++ b/model/ranking.py @@ -1,16 +1,15 @@ -from flask import Blueprint, request +from fastapi import APIRouter from mongo import * -from .auth import * from .utils import * from mongo import engine -__all__ = ['ranking_api'] +__all__ = ['ranking_router'] -ranking_api = Blueprint('ranking_api', __name__) +ranking_router = APIRouter() -@ranking_api.route('/', methods=['GET']) +@ranking_router.get('') def get_ranking(): data = list({ "user": user.info, diff --git a/model/submission.py b/model/submission.py index 2cdd8b2d..a56c3321 100644 --- a/model/submission.py +++ b/model/submission.py @@ -1,24 +1,22 @@ import io -from typing import Optional -import requests as rq +import os import random import secrets import json -from flask import ( - Blueprint, - send_file, - request, - current_app, -) +import httpx +from typing import Optional +from fastapi import APIRouter, Depends, UploadFile, File +from fastapi.responses import StreamingResponse, Response from datetime import datetime, timedelta from mongo import * from mongo import engine from mongo.utils import ( RedisCache, drop_none, + is_testing, ) from .utils import * -from .auth import * +from .auth import identity_verify, login_required from .schemas import ( CreateSubmissionBody, GetSubmissionListQuery, @@ -27,17 +25,75 @@ UpdateConfigBody, ) -__all__ = ['submission_api'] -submission_api = Blueprint('submission_api', __name__) +__all__ = ['submission_router'] +submission_router = APIRouter() + + +# /config must be defined before /{submission_id} to avoid path conflict +@submission_router.get('/config') +def get_config(user: User = identity_verify(0), ): + config = Submission.config() + ret = config.to_mongo() + del ret['_cls'] + del ret['_id'] + return HTTPResponse('success.', data=ret) + + +@submission_router.put('/config') +def update_config( + body: UpdateConfigBody, + user: User = identity_verify(0), +): + rate_limit = body.rate_limit + sandbox_instances = body.sandbox_instances + config = Submission.config() + try: + sandbox_instances = [ + *map(lambda s: engine.Sandbox(**s), sandbox_instances) + ] + except engine.ValidationError as e: + return HTTPError('wrong Sandbox schema', 400, data=e.to_dict()) + if not is_testing(): + resps = [] + for sb in sandbox_instances: + try: + resp = httpx.get(f'{sb.url}/status', timeout=5.0) + except httpx.RequestError as e: + resps.append((sb.name, e)) + continue + if not resp.is_success: + resps.append((sb.name, resp)) + if len(resps) != 0: + return HTTPError( + 'some error occurred when check sandbox status', + 400, + data=[{ + 'name': + name, + 'statusCode': + resp.status_code + if isinstance(resp, httpx.Response) else None, + 'response': + resp.text + if isinstance(resp, httpx.Response) else str(resp), + } for name, resp in resps], + ) + try: + config.update(rate_limit=rate_limit, + sandbox_instances=sandbox_instances) + except ValidationError as e: + return HTTPError(str(e), 400) + return HTTPResponse('success.') -@submission_api.post('/') -@login_required -@parse_body(CreateSubmissionBody) -def create_submission(user, body: CreateSubmissionBody): +@submission_router.post('') +def create_submission( + body: CreateSubmissionBody, + user=Depends(login_required), + ip: str = Depends(get_ip), +): language_type = body.language_type problem_id = body.problem_id - # the user reach the rate limit for submitting now = datetime.now() delta = timedelta.total_seconds(now - user.last_submit) if delta <= Submission.config().rate_limit: @@ -46,31 +102,20 @@ def create_submission(user, body: CreateSubmissionBody): 'Submit too fast!\n' f'Please wait for {wait_for:.2f} seconds to submit.', 429, - data={ - 'waitFor': wait_for, - }, - ) # Too many request - # check for fields - if problem_id is None: - return HTTPError( - 'problemId is required!', - 400, + data={'waitFor': wait_for}, ) - # search for problem + if problem_id is None: + return HTTPError('problemId is required!', 400) problem = Problem(problem_id) if not problem: return HTTPError('Unexisted problem id.', 404) - # problem permissoion if not problem.permission(user, Problem.Permission.VIEW): return HTTPError('problem permission denied!', 403) - # check deadline for homework in problem.obj.homeworks: if now < homework.duration.start: return HTTPError('this homework hasn\'t start.', 403) - # ip validation - if not problem.is_valid_ip(get_ip()): + if not problem.is_valid_ip(ip): return HTTPError('Invalid IP address.', 403) - # handwritten problem doesn't need language type if language_type is None: if problem.problem_type != 2: return HTTPError( @@ -82,7 +127,6 @@ def create_submission(user, body: CreateSubmissionBody): }, ) language_type = 3 - # not allowed language if not problem.allowed(language_type): return HTTPError( 'not allowed language', @@ -92,51 +136,42 @@ def create_submission(user, body: CreateSubmissionBody): 'got': language_type }, ) - # check if the user has used all his quota if problem.obj.quota != -1: no_grade_permission = not any( c.permission(user=user, req=Course.Permission.GRADE) for c in map(Course, problem.courses)) - run_out_of_quota = problem.submit_count(user) >= problem.quota if no_grade_permission and run_out_of_quota: return HTTPError('you have used all your quotas', 403) user.problem_submission[str(problem_id)] = problem.submit_count(user) + 1 user.save() - # insert submission to DB - ip_addr = request.headers.get('cf-connecting-ip', request.remote_addr) try: - submission = Submission.add(problem_id=problem_id, - username=user.username, - lang=language_type, - timestamp=now, - ip_addr=ip_addr) + submission = Submission.add( + problem_id=problem_id, + username=user.username, + lang=language_type, + timestamp=now, + ip_addr=ip, + ) except ValidationError: return HTTPError('invalid data!', 400) except engine.DoesNotExist as e: return HTTPError(str(e), 404) except TestCaseNotFound as e: return HTTPError(str(e), 403) - # update user - user.update( - last_submit=now, - push__submissions=submission.obj, - ) - # update problem + user.update(last_submit=now, push__submissions=submission.obj) submission.problem.update(inc__submitter=1) return HTTPResponse( - 'submission recieved.\n' - 'please send source code with given submission id later.', - data={ - 'submissionId': submission.id, - }, + 'submission recieved.\nplease send source code with given submission id later.', + data={'submissionId': submission.id}, ) -@submission_api.get('/') -@login_required -@parse_query(GetSubmissionListQuery) -def get_submission_list(user, query: GetSubmissionListQuery): +@submission_router.get('') +def get_submission_list( + query: GetSubmissionListQuery = Depends(), + user=Depends(login_required), +): offset = query.offset count = query.count problem_id = query.problem_id @@ -147,11 +182,8 @@ def get_submission_list(user, query: GetSubmissionListQuery): after = query.after language_type = query.language_type ip_addr = query.ip_addr - ''' - get the list of submission data - ''' - def parse_int(val: Optional[int], name: str): + def parse_int(val, name): if val is None: return None try: @@ -159,15 +191,12 @@ def parse_int(val: Optional[int], name: str): except ValueError: raise ValueError(f'can not convert {name} to integer') - def parse_str(val: Optional[str], name: str): + def parse_str(val, name): if val is None: return None - try: - return str(val) - except ValueError: - raise ValueError(f'can not convert {name} to string') + return str(val) - def parse_timestamp(val: Optional[int], name: str): + def parse_timestamp(val, name): if val is None: return None try: @@ -175,29 +204,26 @@ def parse_timestamp(val: Optional[int], name: str): except ValueError: raise ValueError(f'can not convert {name} to timestamp') - cache_key = ( - 'SUBMISSION_LIST_API', - user, - problem_id, - username, - status, - language_type, - course, - offset, - count, - before, - after, - ) - - cache_key = '_'.join(map(str, cache_key)) + cache_key = '_'.join( + map(str, ( + 'SUBMISSION_LIST_API', + user, + problem_id, + username, + status, + language_type, + course, + offset, + count, + before, + after, + ))) cache = RedisCache() - # check cache if cache.exists(cache_key): submissions = json.loads(cache.get(cache_key)) submission_count = submissions['submission_count'] submissions = submissions['submissions'] else: - # convert args offset = parse_int(offset, 'offset') count = parse_int(count, 'count') problem_id = parse_int(problem_id, 'problemId') @@ -209,12 +235,9 @@ def parse_timestamp(val: Optional[int], name: str): if language_type is not None: try: language_type = list(map(int, language_type.split(','))) - except ValueError as e: - return HTTPError( - 'cannot parse integers from languageType', - 400, - ) - # students can only get their own submissions + except ValueError: + return HTTPError('cannot parse integers from languageType', + 400) if user.role == User.engine.Role.STUDENT: username = user.username try: @@ -229,22 +252,21 @@ def parse_timestamp(val: Optional[int], name: str): 'course': course, 'before': before, 'after': after, - 'ip_addr': ip_addr + 'ip_addr': ip_addr, }) - submissions, submission_count = Submission.filter( - **params, - with_count=True, - ) + submissions, submission_count = Submission.filter(**params, + with_count=True) submissions = [s.to_dict() for s in submissions] cache.set( cache_key, json.dumps({ 'submissions': submissions, - 'submission_count': submission_count, - }), 15) + 'submission_count': submission_count + }), + 15, + ) except ValueError as e: return HTTPError(str(e), 400) - # unicorn gifs unicorns = [ 'https://media.giphy.com/media/xTiTnLmaxrlBHxsMMg/giphy.gif', 'https://media.giphy.com/media/26AHG5KGFxSkUWw1i/giphy.gif', @@ -256,29 +278,25 @@ def parse_timestamp(val: Optional[int], name: str): 'submissions': submissions, 'submissionCount': submission_count, } - return HTTPResponse( - 'here you are, bro', - data=ret, - ) + return HTTPResponse('here you are, bro', data=ret) -@submission_api.route('/', methods=['GET']) -@login_required -@Request.doc('submission', Submission) -def get_submission(user, submission: Submission): +@submission_router.get('/{submission_id}') +def get_submission( + ip: str = Depends(get_ip), + user=Depends(login_required), + submission: Submission = get_doc('submission_id', Submission), +): user_feedback_perm = submission.permission(user, Submission.Permission.FEEDBACK) - # check permission if submission.handwritten and not user_feedback_perm: return HTTPError('forbidden.', 403) - # ip validation problem = Problem(submission.problem_id) - if not problem.is_valid_ip(get_ip()): + if not problem.is_valid_ip(ip): return HTTPError('Invalid IP address.', 403) if not all(submission.timestamp in hw.duration for hw in problem.running_homeworks() if hw.ip_filters): return HTTPError('You cannot view this submission during quiz.', 403) - # serialize submission has_code = not submission.handwritten and user_feedback_perm has_output = submission.problem.can_view_stdout ret = submission.to_dict() @@ -287,21 +305,17 @@ def get_submission(user, submission: Submission): ret['code'] = submission.get_main_code() except UnicodeDecodeError: ret['code'] = False - if has_output: - ret['tasks'] = submission.get_detailed_result() - else: - ret['tasks'] = submission.get_result() + ret['tasks'] = submission.get_detailed_result( + ) if has_output else submission.get_result() return HTTPResponse(data=ret) -@submission_api.get('//output//') -@login_required -@Request.doc('submission', Submission) +@submission_router.get('/{submission_id}/output/{task_no}/{case_no}') def get_submission_output( - user, - submission: Submission, - task_no: int, - case_no: int, + task_no: int, + case_no: int, + user=Depends(login_required), + submission: Submission = get_doc('submission_id', Submission), ): if not submission.permission(user, Submission.Permission.VIEW_OUTPUT): return HTTPError('permission denied', 403) @@ -314,14 +328,14 @@ def get_submission_output( return HTTPResponse('ok', data=output) -@submission_api.route('//pdf/', methods=['GET']) -@login_required -@Request.doc('submission', Submission) -def get_submission_pdf(user, submission: Submission, item): - # check the permission +@submission_router.get('/{submission_id}/pdf/{item}') +def get_submission_pdf( + item: str, + user=Depends(login_required), + submission: Submission = get_doc('submission_id', Submission), +): if not submission.permission(user, Submission.Permission.FEEDBACK): return HTTPError('forbidden.', 403) - # non-handwritten submissions have no pdf file if not submission.handwritten: return HTTPError('it is not a handwritten submission.', 400) if item not in ['comment', 'upload']: @@ -331,71 +345,59 @@ def get_submission_pdf(user, submission: Submission, item): data = submission.get_comment() else: data = submission.get_code('main.pdf', binary=True) - except FileNotFoundError as e: + except FileNotFoundError: return HTTPError('File not found.', 404) - return send_file( - io.BytesIO(data), - mimetype='application/pdf', - as_attachment=True, - max_age=0, - download_name=f'{item}-{submission.id[-6:] or "missing-id"}.pdf', + return Response( + content=data, + media_type='application/pdf', + headers={ + 'Content-Disposition': + (f'attachment; filename="{item}-{submission.id[-6:] or "missing-id"}.pdf"' + ), + 'Cache-Control': + 'no-store', + }, ) -@submission_api.put('//complete') -@parse_body(OnSubmissionCompleteBody) -@Request.doc('submission', Submission) -def on_submission_complete(submission: Submission, - body: OnSubmissionCompleteBody): - tasks = body.tasks - token = body.token - if not Submission.verify_token(submission.id, token): +@submission_router.put('/{submission_id}/complete') +def on_submission_complete( + body: OnSubmissionCompleteBody, + submission: Submission = get_doc('submission_id', Submission), +): + if not Submission.verify_token(submission.id, body.token): return HTTPError('i don\'t know you', 403) try: - submission.process_result(tasks) + submission.process_result(body.tasks) except (ValidationError, KeyError) as e: - return HTTPError( - 'invalid data!\n' - f'{type(e).__name__}: {e}', - 400, - ) + return HTTPError(f'invalid data!\n{type(e).__name__}: {e}', 400) return HTTPResponse(f'{submission} result recieved.') -@submission_api.route('/', methods=['PUT']) -@login_required -@Request.doc('submission', Submission) -@Request.files('code') -def update_submission(user, submission: Submission, code): - # validate this reques +@submission_router.put('/{submission_id}') +def update_submission( + submission_id: str, + code: Optional[UploadFile] = File(default=None), + user=Depends(login_required), + submission: Submission = get_doc('submission_id', Submission), + http_client: httpx.Client = Depends(get_http_client), +): if submission.status >= 0: - return HTTPError( - f'{submission} has finished judgement.', - 403, - ) - # if user not equal, reject + return HTTPError(f'{submission} has finished judgement.', 403) if not secrets.compare_digest(submission.user.username, user.username): return HTTPError('user not equal!', 403) - # if source code not found if code is None: - return HTTPError( - f'can not find the source file', - 400, - ) - # or empty file - if len(code.read()) == 0: + return HTTPError('can not find the source file', 400) + content = code.file.read() + if len(content) == 0: return HTTPError('empty file', 400) - code.seek(0) - # has been uploaded + code_file = io.BytesIO(content) if submission.has_code(): - return HTTPError( - f'{submission} has been uploaded source file!', - 403, - ) + return HTTPError(f'{submission} has been uploaded source file!', 403) try: - success = submission.submit(code) - except FileExistsError: - exit(10086) + success = submission.submit(code_file, client=http_client) + except FileExistsError as e: + return HTTPError(str(e), 409) except ValueError as e: return HTTPError(str(e), 400) except JudgeQueueFullError as e: @@ -412,49 +414,46 @@ def update_submission(user, submission: Submission, code): return HTTPError('Some error occurred, please contact the admin', 500) -@submission_api.put('//grade') -@login_required -@parse_body(GradeSubmissionBody) -@Request.doc('submission', Submission) -def grade_submission(user: User, submission: Submission, - body: GradeSubmissionBody): - score = body.score +@submission_router.put('/{submission_id}/grade') +def grade_submission( + body: GradeSubmissionBody, + user: User = Depends(login_required), + submission: Submission = get_doc('submission_id', Submission), +): if not submission.permission(user, Submission.Permission.GRADE): return HTTPError('forbidden.', 403) - - if score < 0 or score > 100: + if body.score < 0 or body.score > 100: return HTTPError('score must be between 0 to 100.', 400) - - # AC if the score is 100, WA otherwise - submission.update(score=score, status=(0 if score == 100 else 1)) + submission.update(score=body.score, status=(0 if body.score == 100 else 1)) submission.finish_judging() return HTTPResponse(f'{submission} score recieved.') -@submission_api.route('//comment', methods=['PUT']) -@login_required -@Request.files('comment') -@Request.doc('submission', Submission) -def comment_submission(user, submission: Submission, comment): +@submission_router.put('/{submission_id}/comment') +async def comment_submission( + submission_id: str, + comment: Optional[UploadFile] = File(default=None), + user=Depends(login_required), + submission: Submission = get_doc('submission_id', Submission), +): if not submission.permission(user, Submission.Permission.COMMENT): return HTTPError('forbidden.', 403) - if comment is None: - return HTTPError( - f'can not find the comment', - 400, - ) + return HTTPError('can not find the comment', 400) + content = await comment.read() try: - submission.add_comment(comment) + submission.add_comment(io.BytesIO(content)) except ValueError as e: return HTTPError(str(e), 400) return HTTPResponse(f'{submission} comment recieved.') -@submission_api.route('//rejudge', methods=['GET']) -@login_required -@Request.doc('submission', Submission) -def rejudge(user, submission: Submission): +@submission_router.get('/{submission_id}/rejudge') +def rejudge( + user=Depends(login_required), + submission: Submission = get_doc('submission_id', Submission), + http_client: httpx.Client = Depends(get_http_client), +): if submission.status == -2 or (submission.status == -1 and (datetime.now() - submission.last_send).seconds < 300): @@ -462,7 +461,7 @@ def rejudge(user, submission: Submission): if not submission.permission(user, Submission.Permission.REJUDGE): return HTTPError('forbidden.', 403) try: - success = submission.rejudge() + success = submission.rejudge(client=http_client) except ValueError as e: return HTTPError(str(e), 400) except JudgeQueueFullError as e: @@ -475,93 +474,23 @@ def rejudge(user, submission: Submission): return HTTPError('Some error occurred, please contact the admin', 500) -@submission_api.get('/config') -@login_required -@identity_verify(0) -def get_config(user): - config = Submission.config() - ret = config.to_mongo() - del ret['_cls'] - del ret['_id'] - return HTTPResponse('success.', data=ret) - - -@submission_api.put('/config') -@login_required -@identity_verify(0) -@parse_body(UpdateConfigBody) -def update_config(user, body: UpdateConfigBody): - rate_limit = body.rate_limit - sandbox_instances = body.sandbox_instances - config = Submission.config() - # try to convert json object to Sandbox instance - try: - sandbox_instances = [ - *map( - lambda s: engine.Sandbox(**s), - sandbox_instances, - ) - ] - except engine.ValidationError as e: - return HTTPError( - 'wrong Sandbox schema', - 400, - data=e.to_dict(), - ) - # skip if during testing - if not current_app.config['TESTING']: - resps = [] - # check sandbox status - for sb in sandbox_instances: - resp = rq.get(f'{sb.url}/status') - if not resp.ok: - resps.append((sb.name, resp)) - # some exception occurred - if len(resps) != 0: - return HTTPError( - 'some error occurred when check sandbox status', - 400, - data=[{ - 'name': name, - 'statusCode': resp.status_code, - 'response': resp.text, - } for name, resp in resps], - ) - try: - config.update( - rate_limit=rate_limit, - sandbox_instances=sandbox_instances, - ) - except ValidationError as e: - return HTTPError(str(e), 400) - return HTTPResponse('success.') - - -@submission_api.post('//migrate-code') -@login_required -@identity_verify(0) -@Request.doc('submission', Submission) -def migrate_code(user: User, submission: Submission): - if not submission.permission( - user, - Submission.Permission.MANAGER, - ): +@submission_router.post('/{submission_id}/migrate-code') +def migrate_code( + user: User = identity_verify(0), + submission: Submission = get_doc('submission_id', Submission), +): + if not submission.permission(user, Submission.Permission.MANAGER): return HTTPError('forbidden.', 403) - submission.migrate_code_to_minio() return HTTPResponse('ok') -@submission_api.post('//migrate-output') -@login_required -@identity_verify(0) -@Request.doc('submission', Submission) -def migrate_output(user: User, submission: Submission): - if not submission.permission( - user, - Submission.Permission.MANAGER, - ): +@submission_router.post('/{submission_id}/migrate-output') +def migrate_output( + user: User = identity_verify(0), + submission: Submission = get_doc('submission_id', Submission), +): + if not submission.permission(user, Submission.Permission.MANAGER): return HTTPError('forbidden.', 403) - submission.migrate_output_to_minio() return HTTPResponse('ok') diff --git a/model/test.py b/model/test.py index 16f01652..08b70fa1 100644 --- a/model/test.py +++ b/model/test.py @@ -1,36 +1,37 @@ -from flask import Blueprint, current_app, request +import logging +from fastapi import APIRouter, Depends, Request -from .auth import * +from .auth import login_required, identity_verify from .utils import * -__all__ = ['test_api'] +__all__ = ['test_router'] -test_api = Blueprint('test_api', __name__) +logger = logging.getLogger(__name__) +test_router = APIRouter() -@test_api.route('/') -@login_required -def test(user): + +@test_router.get('') +def test(user=Depends(login_required)): return HTTPResponse(user.username) -@test_api.route('/role') -@identity_verify(0, 1, ...) -def role(user): +@test_router.get('/role') +def role(user=identity_verify(0, 1, ...)): return HTTPResponse(str(user.obj.role)) -@test_api.route('/log') +@test_router.get('/log') def log(): - current_app.logger.debug('this is a DEBUG log') - current_app.logger.info('this is a INFO log') - current_app.logger.warning('this is a WARNING log') - current_app.logger.error('this is a ERROR log') - current_app.logger.critical('this is a CRITICAL log') + logger.debug('this is a DEBUG log') + logger.info('this is a INFO log') + logger.warning('this is a WARNING log') + logger.error('this is a ERROR log') + logger.critical('this is a CRITICAL log') return HTTPResponse('check the log') -@test_api.route('/header') -def check_header(): - current_app.logger.debug(f'{request.headers}') +@test_router.get('/header') +def check_header(request: Request): + logger.debug(f'{request.headers}') return HTTPResponse('ok') diff --git a/model/user.py b/model/user.py index 800d2ef3..0ea45745 100644 --- a/model/user.py +++ b/model/user.py @@ -1,6 +1,7 @@ +import logging from urllib import parse from typing import Optional -from flask import Blueprint, current_app, request +from fastapi import APIRouter, Depends from mongo import engine from mongo.utils import drop_none from mongo import * @@ -8,33 +9,27 @@ from .auth import identity_verify, login_required from .schemas import AddUserBody, UpdateUserBody, GetUserListQuery -__all__ = ['user_api'] +__all__ = ['user_router', 'user_options_router'] -user_api = Blueprint('user_api', __name__) +logger = logging.getLogger(__name__) +# Handles CORS preflight (OPTIONS) without authentication, matching original Flask before_request behavior +user_options_router = APIRouter() -@identity_verify(0) -def check_admin(user): - ''' - an empty wrapper to check whether client is admin - ''' - return None +@user_options_router.options('/{path:path}') +@user_options_router.options('') +def _user_options_handler(): + from fastapi.responses import Response + return Response(status_code=200) -@user_api.before_request -def before_request(): - ''' - we only allow admins to call user APIs, but the CORS preflight - request won't contain credentials, so we skip the check for that. - ''' - if request.method.lower() == 'options': - return None - return check_admin() +# All user-management endpoints require admin access +user_router = APIRouter(dependencies=[identity_verify(0)]) -@user_api.get('/') -@parse_query(GetUserListQuery) -def get_user_list(query: GetUserListQuery): + +@user_router.get('') +def get_user_list(query: GetUserListQuery = Depends()): offset = query.offset count = query.count course = query.course @@ -47,20 +42,12 @@ def get_user_list(query: GetUserListQuery): if role is not None: role = int(role) except (TypeError, ValueError): - return HTTPError( - 'offset, count and role must be integer', - 400, - ) + return HTTPError('offset, count and role must be integer', 400) if course is not None: course = parse.unquote(course) - # filter - query = drop_none({ - 'courses': course, - 'role': role, - }) - user_list = engine.User.objects(**query) - # truncate + filter_query = drop_none({'courses': course, 'role': role}) + user_list = engine.User.objects(**filter_query) if offset is not None: user_list = user_list[offset:] if count is not None: @@ -70,52 +57,40 @@ def get_user_list(query: GetUserListQuery): return HTTPResponse(data=user_list) -@user_api.get('/summary') +@user_router.get('/summary') def get_user_summary(): user_count = engine.User.objects.count() breakdown = [{ "role": role.name.lower(), "count": engine.User.objects(role=role.value).count() } for role in engine.User.Role] - return HTTPResponse(data={ - "userCount": user_count, - "breakdown": breakdown, - }) + return HTTPResponse(data={"userCount": user_count, "breakdown": breakdown}) -@user_api.post('/') -@parse_body(AddUserBody) +@user_router.post('') def add_user(body: AddUserBody): - ''' - Directly add a user without activation required. - This operation only allow admin to use. - ''' + '''Directly add a user without activation required. Admin only.''' try: - User.signup( - body.username, - body.password, - body.email, - ).activate() + User.signup(body.username, body.password, body.email).activate() except ValidationError as ve: return HTTPError('Signup Failed', 400, data=ve.to_dict()) except NotUniqueError: return HTTPError('User Exists', 400) - except ValueError as ve: + except ValueError: return HTTPError('Not Allowed Name', 400) return HTTPResponse() -@user_api.patch('/') -@login_required -@Request.doc('username', 'target_user', User) -@parse_body(UpdateUserBody) -def update_user(user: User, target_user: User, body: UpdateUserBody): - # TODO: notify admin & user (by email, SMS, etc.) +@user_router.patch('/{username}') +def update_user( + body: UpdateUserBody, + user: User = Depends(login_required), + target_user: User = get_doc('username', User), +): if body.password is not None: target_user.change_password(body.password) - current_app.logger.info( - 'admin changed user password ' - f'[actor={user.username}, user={target_user.username}]', ) + logger.info('admin changed user password ' + f'[actor={user.username}, user={target_user.username}]') payload = drop_none({ 'profile__displayed_name': body.displayed_name, 'role': body.role, @@ -123,8 +98,8 @@ def update_user(user: User, target_user: User, body: UpdateUserBody): if len(payload): fields = [*payload.keys()] target_user.update(**payload) - current_app.logger.info( + logger.info( 'admin changed user info ' - f'[actor={user.username}, user={target_user.username}, fields={fields}]', + f'[actor={user.username}, user={target_user.username}, fields={fields}]' ) return HTTPResponse() diff --git a/model/utils/request.py b/model/utils/request.py index 25fa1047..15648655 100644 --- a/model/utils/request.py +++ b/model/utils/request.py @@ -1,147 +1,58 @@ -import time -import json -from functools import wraps -from flask import request, current_app -from pydantic import ValidationError as PydanticValidationError +import inspect +import httpx +from fastapi import Depends, Request from mongo import engine -from mongo.utils import doc_required -from .response import * - -__all__ = ( - 'Request', - 'parse_body', - 'parse_query', - 'get_ip', -) - -type_map = { - 'int': int, - 'list': list, - 'str': str, - 'dict': dict, - 'bool': bool, - 'None': type(None) -} - - -class _Request(type): - - def __getattr__(self, content_type): - - def get(*keys, vars_dict={}): - - def data_func(func): - - @wraps(func) - def wrapper(*args, **kwargs): - data = getattr(request, content_type) - if data == None: - # FIXME: This exception doesn't mean the content type is wrong - return HTTPError( - f'Unaccepted Content-Type {content_type}', 415) - try: - # Magic - # yapf: disable - kwargs.update({ - k: (lambda v: v - if t is None or type(v) is t else int(''))( - data.get((lambda s, *t: s + ''.join( - map(str.capitalize, t)) - )(*filter(bool, k.split('_'))))) - for k, t in map( - lambda x: - (x[0], (x[1:] or None) and type_map.get(x[1].strip())), - map(lambda q: q.split(':', 1), keys)) - }) - # yapf: enable - except ValueError as ve: - return HTTPError('Requested Value With Wrong Type', - 400) - kwargs.update( - {v: data.get(vars_dict[v]) - for v in vars_dict}) - return func(*args, **kwargs) - - return wrapper - - return data_func - - return get - - -class Request(metaclass=_Request): - - @staticmethod - def doc(src, des, cls=None, src_none_allowed=False): - ''' - a warpper to `doc_required` for flask route - ''' - - def deco(func): - - @doc_required(src, des, cls, src_none_allowed) - def inner_wrapper(*args, **ks): - return func(*args, **ks) - - @wraps(func) - def real_wrapper(*args, **ks): - try: - return inner_wrapper(*args, **ks) - # if document not exists in db - except engine.DoesNotExist as e: - return HTTPError(str(e), 404) - # if args missing - except TypeError as e: - return HTTPError(str(e), 500) - except engine.ValidationError as e: - current_app.logger.info( - f'Validation error [err={e.to_dict()}]') - return HTTPError('Invalid parameter', 400) - - return real_wrapper - - return deco - - -def parse_body(schema_cls): - """Replaces @Request.json — validates JSON request body against a Pydantic schema.""" - - def decorator(func): - - @wraps(func) - def wrapper(*args, **kwargs): - data = request.get_json(silent=True) or {} - try: - kwargs['body'] = schema_cls.model_validate(data) - except PydanticValidationError as e: - return HTTPError('Invalid request body', 400, data=e.errors()) - return func(*args, **kwargs) - - return wrapper - - return decorator - - -def parse_query(schema_cls): - """Replaces @Request.args — validates query string against a Pydantic schema.""" - - def decorator(func): - - @wraps(func) - def wrapper(*args, **kwargs): - try: - kwargs['query'] = schema_cls.model_validate(dict(request.args)) - except PydanticValidationError as e: - return HTTPError('Invalid query parameters', - 400, - data=e.errors()) - return func(*args, **kwargs) - - return wrapper - - return decorator - - -def get_ip() -> str: - ip = request.headers.get('X-Forwarded-For', '').split(',')[-1].strip() - return ip +from .response import NOJException + +__all__ = ('get_doc', 'get_ip', 'get_http_client') + + +def get_doc(src_param: str, cls, param_type=str): + """ + Depends factory: resolves a MongoEngine document from a path/query parameter. + + Usage: + @router.get('/{username}') + def handler(target_user = get_doc('username', User)): ... + + @router.get('/{problem_id}') + def handler(problem = get_doc('problem_id', Problem, int)): ... + """ + + def dependency(**kwargs): + val = kwargs[src_param] + try: + doc = cls(val) + if not doc: + raise engine.DoesNotExist(f'{cls.__name__} not found') + return doc + except engine.DoesNotExist as e: + raise NOJException(str(e), 404) + except engine.ValidationError: + raise NOJException('Invalid parameter', 400) + + # Give the function a proper named signature so FastAPI can inject the path param + param = inspect.Parameter( + src_param, + kind=inspect.Parameter.POSITIONAL_OR_KEYWORD, + annotation=param_type, + ) + dependency.__signature__ = inspect.Signature([param]) + dependency.__annotations__ = {src_param: param_type} + return Depends(dependency) + + +def get_http_client(request: Request) -> httpx.Client: + return request.app.state.http_client + + +def get_ip(request: Request) -> str: + # cf-connecting-ip is set by Cloudflare and is more reliable than X-Forwarded-For + cf_ip = request.headers.get('cf-connecting-ip', '').strip() + if cf_ip: + return cf_ip + forwarded_for = request.headers.get('X-Forwarded-For', + '').split(',')[-1].strip() + if forwarded_for: + return forwarded_for + return request.client.host if request.client else '' diff --git a/model/utils/response.py b/model/utils/response.py index c81fd4ca..819f1458 100644 --- a/model/utils/response.py +++ b/model/utils/response.py @@ -1,80 +1,61 @@ -from flask import jsonify, redirect +from typing import Any +from fastapi import HTTPException +from fastapi.responses import JSONResponse, RedirectResponse -__all__ = ['HTTPResponse', 'HTTPRedirect', 'HTTPError'] +__all__ = ['NOJException', 'HTTPResponse', 'HTTPRedirect', 'HTTPError'] -class HTTPBaseResponese(tuple): +class NOJException(HTTPException): + """Carries the {status, message, data} envelope used by all NOJ error responses.""" - def __new__( - cls, - resp, - status_code=200, - cookies={}, - ): - for c in cookies: - if cookies[c] == None: - resp.delete_cookie(c) - else: - d = c.split('_httponly') - resp.set_cookie(d[0], cookies[c], httponly=bool(d[1:])) - return super().__new__(tuple, (resp, status_code)) + def __init__(self, message: str, status_code: int, data: Any = None): + super().__init__(status_code=status_code) + self.message = message + self.data = data -class HTTPResponse(HTTPBaseResponese): +def _apply_cookies(resp, cookies: dict): + for k, v in cookies.items(): + if v is None: + resp.delete_cookie(k.replace('_httponly', '')) + else: + httponly = k.endswith('_httponly') + resp.set_cookie(k.replace('_httponly', ''), v, httponly=httponly) + return resp - def __new__( - cls, - message='', - status_code=200, - status='ok', - data=None, - cookies={}, - ): - resp = jsonify({ + +def HTTPResponse( + message: str = '', + status_code: int = 200, + status: str = 'ok', + data: Any = None, + cookies: dict | None = None, +) -> JSONResponse: + resp = JSONResponse( + { 'status': status, 'message': message, 'data': data, - }) - return super().__new__( - HTTPBaseResponese, - resp, - status_code, - cookies, - ) - - -class HTTPRedirect(HTTPBaseResponese): - - def __new__( - cls, - location, - status_code=302, - cookies={}, - ): - resp = redirect(location) - return super().__new__( - HTTPBaseResponese, - resp, - status_code, - cookies, - ) - - -class HTTPError(HTTPResponse): - - def __new__( - cls, - message, - status_code, - data=None, - logout=False, - ): - cookies = {'piann': None, 'jwt': None} if logout else {} - return super().__new__( - HTTPResponse, - message, - status_code, - 'err', - data, - cookies, - ) + }, + status_code=status_code, + ) + return _apply_cookies(resp, cookies or {}) + + +def HTTPRedirect( + location: str, + status_code: int = 302, + cookies: dict | None = None, +) -> RedirectResponse: + resp = RedirectResponse(location, status_code=status_code) + return _apply_cookies(resp, cookies or {}) + + +def HTTPError( + message: str, + status_code: int, + data: Any = None, + logout: bool = False, +) -> JSONResponse: + cookies = {'piann': None, 'jwt': None} if logout else {} + return HTTPResponse(message, status_code, 'err', data, cookies) diff --git a/mongo/base.py b/mongo/base.py index c010bdd6..b904b382 100644 --- a/mongo/base.py +++ b/mongo/base.py @@ -1,4 +1,3 @@ -from flask import current_app from . import engine from mongoengine.errors import * import logging @@ -58,7 +57,4 @@ def reload(self, *fields): @property def logger(self): - try: - return current_app.logger - except RuntimeError: - return logging.getLogger('gunicorn.error') \ No newline at end of file + return logging.getLogger(__name__) \ No newline at end of file diff --git a/mongo/homework.py b/mongo/homework.py index bf8e0165..e13d9f91 100644 --- a/mongo/homework.py +++ b/mongo/homework.py @@ -56,7 +56,7 @@ def add( user, course: Course, hw_name: str, - problem_ids: List[int] = [], + problem_ids: List[int] | None = None, markdown: str = '', scoreboard_status: int = 0, start: Optional[float] = None, @@ -80,6 +80,7 @@ def add( elif penalty_stat == Error.Invalid_penalty: raise ValueError("Invalid penalty") + problem_ids = problem_ids if problem_ids is not None else [] problems = [*map(Problem, problem_ids)] if not all(problems): raise engine.DoesNotExist('some problems not found!') diff --git a/mongo/problem/test_case.py b/mongo/problem/test_case.py index b4807f3e..1bd7aed7 100644 --- a/mongo/problem/test_case.py +++ b/mongo/problem/test_case.py @@ -53,8 +53,8 @@ class SimpleIO(TestCaseRule): Test cases that only contains single input and output file. ''' - def __init__(self, problem: 'Problem', excludes: List[str] = []): - self.excludes = excludes + def __init__(self, problem: 'Problem', excludes: List[str] | None = None): + self.excludes = excludes if excludes is not None else [] super().__init__(problem) def validate(self, test_case: BinaryIO) -> bool: diff --git a/mongo/submission.py b/mongo/submission.py index 953ab1de..9c3ab193 100644 --- a/mongo/submission.py +++ b/mongo/submission.py @@ -14,10 +14,10 @@ ) import enum import tempfile -import requests as rq +import httpx from hashlib import md5 from bson.son import SON -from flask import current_app +import os from tempfile import NamedTemporaryFile from datetime import date, datetime from zipfile import ZipFile, is_zipfile @@ -29,7 +29,7 @@ from .problem import Problem from .homework import Homework from .course import Course -from .utils import RedisCache, MinioClient +from .utils import RedisCache, MinioClient, is_testing __all__ = [ 'SubmissionConfig', @@ -289,12 +289,14 @@ def on_403(resp): f'body: {resp.text}', ) return False - def target_sandbox(self): + def target_sandbox(self, client: httpx.Client | None = None): + if client is None: + client = httpx.Client(timeout=5.0) load = 10**3 # current min load tar = None # target for sb in self.config().sandbox_instances: - resp = rq.get(f'{sb.url}/status') - if not resp.ok: + resp = client.get(f'{sb.url}/status') + if not resp.is_success: self.logger.warning(f'sandbox {sb.name} status exception') self.logger.warning( f'status code: {resp.status_code}\n ' @@ -343,7 +345,7 @@ def _check_code(self, file): file.seek(0) return None - def rejudge(self) -> bool: + def rejudge(self, client: httpx.Client | None = None) -> bool: ''' rejudge this submission ''' @@ -355,9 +357,9 @@ def rejudge(self) -> bool: last_send=datetime.now(), tasks=[], ) - if current_app.config['TESTING']: + if is_testing(): return True - return self.send() + return self.send(client=client) def _generate_code_minio_path(self): return f'submissions/{self.id}_{ULID()}.zip' @@ -381,7 +383,7 @@ def _put_code(self, code_file) -> str: ) return path - def submit(self, code_file) -> bool: + def submit(self, code_file, client: httpx.Client | None = None) -> bool: ''' prepare data for submit code to sandbox and then send it @@ -416,17 +418,19 @@ def submit(self, code_file) -> bool: homework.save() submission.delete() # we no need to actually send code to sandbox during testing - if current_app.config['TESTING'] or self.handwritten: + if is_testing() or self.handwritten: return True - return self.send() + return self.send(client=client) - def send(self) -> bool: + def send(self, client: httpx.Client | None = None) -> bool: ''' send code to sandbox ''' if self.handwritten: logging.warning(f'try to send a handwritten {self}') return False + if client is None: + client = httpx.Client(timeout=httpx.Timeout(5.0, read=30.0)) # TODO: Ensure problem is ready to submitted # if not Problem(self.problem).is_test_case_ready(): # raise TestCaseNotFound(self.problem.problem_id) @@ -435,7 +439,7 @@ def send(self) -> bool: 'src': io.BytesIO(b"".join(self._get_code_raw())), } # look for the target sandbox - tar = self.target_sandbox() + tar = self.target_sandbox(client=client) if tar is None: self.logger.error(f'can not target a sandbox for {repr(self)}') return False @@ -450,7 +454,7 @@ def send(self) -> bool: judge_url = f'{tar.url}/submit/{self.id}' # send submission to snadbox for judgement self.logger.info(f'send {self} to {tar.name}') - resp = rq.post( + resp = client.post( judge_url, data=post_data, files=files, diff --git a/mongo/utils.py b/mongo/utils.py index 67ba1f8e..acb18596 100644 --- a/mongo/utils.py +++ b/mongo/utils.py @@ -1,9 +1,9 @@ import abc import hashlib +import logging import os from functools import wraps from typing import Dict, Optional, Any, TYPE_CHECKING -from flask import current_app from minio import Minio import redis from . import engine @@ -16,6 +16,7 @@ __all__ = ( 'hash_id', + 'is_testing', 'perm', 'RedisCache', 'doc_required', @@ -23,6 +24,16 @@ ) +def is_testing() -> bool: + '''Return True if the app is running in test mode. + + Reads the ``TESTING`` environment variable and interprets ``1``, ``true``, + and ``yes`` (case-insensitive) as True. Any other value — including the + common mistake of setting ``TESTING=false`` — is treated as False. + ''' + return os.getenv('TESTING', '').lower() in ('1', 'true', 'yes') + + def hash_id(salt, text): text = ((salt or '') + (text or '')).encode() sha = hashlib.sha3_512(text) @@ -159,7 +170,7 @@ def wrapper(*args, **ks): # replace original paramters del ks[src] if des in ks: - current_app.logger.warning( + logging.getLogger(__name__).warning( f'replace a existed argument in {func}') ks[des] = doc return func(*args, **ks) diff --git a/poetry.lock b/poetry.lock index 09c0495e..8bf9e6fe 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand. [[package]] name = "annotated-types" @@ -12,6 +12,25 @@ files = [ {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, ] +[[package]] +name = "anyio" +version = "4.13.0" +description = "High-level concurrency and networking framework on top of asyncio or Trio" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708"}, + {file = "anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc"}, +] + +[package.dependencies] +idna = ">=2.8" +typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} + +[package.extras] +trio = ["trio (>=0.32.0)"] + [[package]] name = "argon2-cffi" version = "25.1.0" @@ -222,7 +241,7 @@ version = "3.4.2" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7" -groups = ["main", "dev"] +groups = ["dev"] files = [ {file = "charset_normalizer-3.4.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c48ed483eb946e6c04ccbe02c6b4d1d48e51944b6db70f697e089c193404941"}, {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2d318c11350e10662026ad0eb71bb51c7812fc8590825304ae0bdd4ac283acd"}, @@ -320,14 +339,14 @@ files = [ [[package]] name = "click" -version = "8.2.1" +version = "8.3.2" description = "Composable command line interface toolkit" optional = false python-versions = ">=3.10" groups = ["main"] files = [ - {file = "click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b"}, - {file = "click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202"}, + {file = "click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d"}, + {file = "click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5"}, ] [package.dependencies] @@ -344,7 +363,7 @@ files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] -markers = {main = "platform_system == \"Windows\"", dev = "sys_platform == \"win32\""} +markers = {main = "platform_system == \"Windows\" or sys_platform == \"win32\"", dev = "sys_platform == \"win32\""} [[package]] name = "coverage" @@ -502,6 +521,27 @@ sortedcontainers = ">=2.4.0,<3.0.0" aioredis = ["aioredis (>=2.0.1,<3.0.0)"] lua = ["lupa (>=1.13,<2.0)"] +[[package]] +name = "fastapi" +version = "0.115.14" +description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "fastapi-0.115.14-py3-none-any.whl", hash = "sha256:6c0c8bf9420bd58f565e585036d971872472b4f7d3f6c73b698e10cffdefb3ca"}, + {file = "fastapi-0.115.14.tar.gz", hash = "sha256:b1de15cdc1c499a4da47914db35d0e4ef8f1ce62b624e94e0e5824421df99739"}, +] + +[package.dependencies] +pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<2.1.0 || >2.1.0,<3.0.0" +starlette = ">=0.40.0,<0.47.0" +typing-extensions = ">=4.8.0" + +[package.extras] +all = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=3.1.5)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"] +standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "jinja2 (>=3.1.5)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"] + [[package]] name = "filelock" version = "3.20.3" @@ -515,50 +555,117 @@ files = [ ] [[package]] -name = "flask" -version = "3.1.3" -description = "A simple framework for building complex web applications." +name = "h11" +version = "0.16.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" optional = false -python-versions = ">=3.9" +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, + {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +description = "A minimal low-level HTTP client." +optional = false +python-versions = ">=3.8" groups = ["main"] files = [ - {file = "flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c"}, - {file = "flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb"}, + {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, + {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, ] [package.dependencies] -blinker = ">=1.9.0" -click = ">=8.1.3" -itsdangerous = ">=2.2.0" -jinja2 = ">=3.1.2" -markupsafe = ">=2.1.1" -werkzeug = ">=3.1.0" +certifi = "*" +h11 = ">=0.16" [package.extras] -async = ["asgiref (>=3.2)"] -dotenv = ["python-dotenv"] +asyncio = ["anyio (>=4.0,<5.0)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +trio = ["trio (>=0.22.0,<1.0)"] [[package]] -name = "gunicorn" -version = "23.0.0" -description = "WSGI HTTP Server for UNIX" +name = "httptools" +version = "0.7.1" +description = "A collection of framework independent HTTP protocol utils." optional = false -python-versions = ">=3.7" +python-versions = ">=3.9" groups = ["main"] files = [ - {file = "gunicorn-23.0.0-py3-none-any.whl", hash = "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d"}, - {file = "gunicorn-23.0.0.tar.gz", hash = "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec"}, + {file = "httptools-0.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:11d01b0ff1fe02c4c32d60af61a4d613b74fad069e47e06e9067758c01e9ac78"}, + {file = "httptools-0.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:84d86c1e5afdc479a6fdabf570be0d3eb791df0ae727e8dbc0259ed1249998d4"}, + {file = "httptools-0.7.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c8c751014e13d88d2be5f5f14fc8b89612fcfa92a9cc480f2bc1598357a23a05"}, + {file = "httptools-0.7.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:654968cb6b6c77e37b832a9be3d3ecabb243bbe7a0b8f65fbc5b6b04c8fcabed"}, + {file = "httptools-0.7.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b580968316348b474b020edf3988eecd5d6eec4634ee6561e72ae3a2a0e00a8a"}, + {file = "httptools-0.7.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d496e2f5245319da9d764296e86c5bb6fcf0cf7a8806d3d000717a889c8c0b7b"}, + {file = "httptools-0.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:cbf8317bfccf0fed3b5680c559d3459cccf1abe9039bfa159e62e391c7270568"}, + {file = "httptools-0.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:474d3b7ab469fefcca3697a10d11a32ee2b9573250206ba1e50d5980910da657"}, + {file = "httptools-0.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3c3b7366bb6c7b96bd72d0dbe7f7d5eead261361f013be5f6d9590465ea1c70"}, + {file = "httptools-0.7.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:379b479408b8747f47f3b253326183d7c009a3936518cdb70db58cffd369d9df"}, + {file = "httptools-0.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cad6b591a682dcc6cf1397c3900527f9affef1e55a06c4547264796bbd17cf5e"}, + {file = "httptools-0.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:eb844698d11433d2139bbeeb56499102143beb582bd6c194e3ba69c22f25c274"}, + {file = "httptools-0.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f65744d7a8bdb4bda5e1fa23e4ba16832860606fcc09d674d56e425e991539ec"}, + {file = "httptools-0.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:135fbe974b3718eada677229312e97f3b31f8a9c8ffa3ae6f565bf808d5b6bcb"}, + {file = "httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5"}, + {file = "httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5"}, + {file = "httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03"}, + {file = "httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2"}, + {file = "httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362"}, + {file = "httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c"}, + {file = "httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321"}, + {file = "httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3"}, + {file = "httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca"}, + {file = "httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c"}, + {file = "httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66"}, + {file = "httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346"}, + {file = "httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650"}, + {file = "httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6"}, + {file = "httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270"}, + {file = "httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3"}, + {file = "httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1"}, + {file = "httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b"}, + {file = "httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60"}, + {file = "httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca"}, + {file = "httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96"}, + {file = "httptools-0.7.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:ac50afa68945df63ec7a2707c506bd02239272288add34539a2ef527254626a4"}, + {file = "httptools-0.7.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:de987bb4e7ac95b99b805b99e0aae0ad51ae61df4263459d36e07cf4052d8b3a"}, + {file = "httptools-0.7.1-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d169162803a24425eb5e4d51d79cbf429fd7a491b9e570a55f495ea55b26f0bf"}, + {file = "httptools-0.7.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49794f9250188a57fa73c706b46cb21a313edb00d337ca4ce1a011fe3c760b28"}, + {file = "httptools-0.7.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:aeefa0648362bb97a7d6b5ff770bfb774930a327d7f65f8208394856862de517"}, + {file = "httptools-0.7.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:0d92b10dbf0b3da4823cde6a96d18e6ae358a9daa741c71448975f6a2c339cad"}, + {file = "httptools-0.7.1-cp39-cp39-win_amd64.whl", hash = "sha256:5ddbd045cfcb073db2449563dd479057f2c2b681ebc232380e63ef15edc9c023"}, + {file = "httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9"}, +] + +[[package]] +name = "httpx" +version = "0.27.2" +description = "The next generation HTTP client." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "httpx-0.27.2-py3-none-any.whl", hash = "sha256:7bb2708e112d8fdd7829cd4243970f0c223274051cb35ee80c03301ee29a3df0"}, + {file = "httpx-0.27.2.tar.gz", hash = "sha256:f7c2be1d2f3c3c3160d441802406b206c2b76f5947b11115e6df10c6c65e66c2"}, ] [package.dependencies] -packaging = "*" +anyio = "*" +certifi = "*" +httpcore = "==1.*" +idna = "*" +sniffio = "*" [package.extras] -eventlet = ["eventlet (>=0.24.1,!=0.36.0)"] -gevent = ["gevent (>=1.4.0)"] -setproctitle = ["setproctitle"] -testing = ["coverage", "eventlet", "gevent", "pytest", "pytest-cov"] -tornado = ["tornado (>=0.2)"] +brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] +cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +zstd = ["zstandard (>=0.18.0)"] [[package]] name = "identify" @@ -602,36 +709,6 @@ files = [ {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, ] -[[package]] -name = "itsdangerous" -version = "2.2.0" -description = "Safely pass data to untrusted environments and back." -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef"}, - {file = "itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173"}, -] - -[[package]] -name = "jinja2" -version = "3.1.6" -description = "A very fast and expressive template engine." -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"}, - {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"}, -] - -[package.dependencies] -MarkupSafe = ">=2.0" - -[package.extras] -i18n = ["Babel (>=2.7)"] - [[package]] name = "lxml" version = "6.0.0" @@ -742,77 +819,6 @@ html-clean = ["lxml_html_clean"] html5 = ["html5lib"] htmlsoup = ["BeautifulSoup4"] -[[package]] -name = "markupsafe" -version = "3.0.2" -description = "Safely add untrusted strings to HTML/XML markup." -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-win32.whl", hash = "sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a"}, - {file = "markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0"}, -] - [[package]] name = "minio" version = "7.2.15" @@ -904,7 +910,7 @@ version = "25.0" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" -groups = ["main", "dev"] +groups = ["dev"] files = [ {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, @@ -1318,7 +1324,7 @@ version = "1.1.1" description = "Read key-value pairs from a .env file and set them as environment variables" optional = false python-versions = ">=3.9" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc"}, {file = "python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab"}, @@ -1327,6 +1333,21 @@ files = [ [package.extras] cli = ["click (>=5.0)"] +[[package]] +name = "python-multipart" +version = "0.0.9" +description = "A streaming multipart parser for Python" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "python_multipart-0.0.9-py3-none-any.whl", hash = "sha256:97ca7b8ea7b05f977dc3849c3ba99d51689822fab725c3703af7c866a0c2b215"}, + {file = "python_multipart-0.0.9.tar.gz", hash = "sha256:03f54688c663f1b7977105f021043b0793151e4cb1c1a9d4a11fc13d622c4026"}, +] + +[package.extras] +dev = ["atomicwrites (==1.4.1)", "attrs (==23.2.0)", "coverage (==7.4.1)", "hatch", "invoke (==2.2.0)", "more-itertools (==10.2.0)", "pbr (==6.0.0)", "pluggy (==1.4.0)", "py (==1.11.0)", "pytest (==8.0.0)", "pytest-cov (==4.1.0)", "pytest-timeout (==2.2.0)", "pyyaml (==6.0.1)", "ruff (==0.2.1)"] + [[package]] name = "python-ulid" version = "3.0.0" @@ -1387,7 +1408,7 @@ version = "6.0.2" description = "YAML parser and emitter for Python" optional = false python-versions = ">=3.8" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, @@ -1465,14 +1486,14 @@ ocsp = ["cryptography (>=36.0.1)", "pyopenssl (==20.0.1)", "requests (>=2.26.0)" [[package]] name = "requests" -version = "2.33.0" +version = "2.33.1" description = "Python HTTP for Humans." optional = false python-versions = ">=3.10" -groups = ["main", "dev"] +groups = ["dev"] files = [ - {file = "requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b"}, - {file = "requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652"}, + {file = "requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a"}, + {file = "requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517"}, ] [package.dependencies] @@ -1483,7 +1504,6 @@ urllib3 = ">=1.26,<3" [package.extras] socks = ["PySocks (>=1.5.6,!=1.5.7)"] -test = ["PySocks (>=1.5.6,!=1.5.7)", "pytest (>=3)", "pytest-cov", "pytest-httpbin (==2.1.0)", "pytest-mock", "pytest-xdist"] use-chardet-on-py3 = ["chardet (>=3.0.2,<8)"] [[package]] @@ -1497,6 +1517,18 @@ files = [ {file = "sentinels-1.0.0.tar.gz", hash = "sha256:7be0704d7fe1925e397e92d18669ace2f619c92b5d4eb21a89f31e026f9ff4b1"}, ] +[[package]] +name = "sniffio" +version = "1.3.1" +description = "Sniff out which async library your code is running under" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, + {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, +] + [[package]] name = "sortedcontainers" version = "2.4.0" @@ -1521,6 +1553,24 @@ files = [ {file = "soupsieve-2.7.tar.gz", hash = "sha256:ad282f9b6926286d2ead4750552c8a6142bc4c783fd66b0293547c8fe6ae126a"}, ] +[[package]] +name = "starlette" +version = "0.46.2" +description = "The little ASGI library that shines." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35"}, + {file = "starlette-0.46.2.tar.gz", hash = "sha256:7f7361f34eed179294600af672f565727419830b54b7b084efe44bb82d2fccd5"}, +] + +[package.dependencies] +anyio = ">=3.6.2,<5" + +[package.extras] +full = ["httpx (>=0.27.0,<0.29.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"] + [[package]] name = "testcontainers" version = "4.10.0" @@ -1633,6 +1683,97 @@ h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] +[[package]] +name = "uvicorn" +version = "0.30.6" +description = "The lightning-fast ASGI server." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "uvicorn-0.30.6-py3-none-any.whl", hash = "sha256:65fd46fe3fda5bdc1b03b94eb634923ff18cd35b2f084813ea79d1f103f711b5"}, + {file = "uvicorn-0.30.6.tar.gz", hash = "sha256:4b15decdda1e72be08209e860a1e10e92439ad5b97cf44cc945fcbee66fc5788"}, +] + +[package.dependencies] +click = ">=7.0" +colorama = {version = ">=0.4", optional = true, markers = "sys_platform == \"win32\" and extra == \"standard\""} +h11 = ">=0.8" +httptools = {version = ">=0.5.0", optional = true, markers = "extra == \"standard\""} +python-dotenv = {version = ">=0.13", optional = true, markers = "extra == \"standard\""} +pyyaml = {version = ">=5.1", optional = true, markers = "extra == \"standard\""} +uvloop = {version = ">=0.14.0,<0.15.0 || >0.15.0,<0.15.1 || >0.15.1", optional = true, markers = "sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\" and extra == \"standard\""} +watchfiles = {version = ">=0.13", optional = true, markers = "extra == \"standard\""} +websockets = {version = ">=10.4", optional = true, markers = "extra == \"standard\""} + +[package.extras] +standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.5.0)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1) ; sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"", "watchfiles (>=0.13)", "websockets (>=10.4)"] + +[[package]] +name = "uvloop" +version = "0.22.1" +description = "Fast implementation of asyncio event loop on top of libuv" +optional = false +python-versions = ">=3.8.1" +groups = ["main"] +markers = "sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"" +files = [ + {file = "uvloop-0.22.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ef6f0d4cc8a9fa1f6a910230cd53545d9a14479311e87e3cb225495952eb672c"}, + {file = "uvloop-0.22.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7cd375a12b71d33d46af85a3343b35d98e8116134ba404bd657b3b1d15988792"}, + {file = "uvloop-0.22.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac33ed96229b7790eb729702751c0e93ac5bc3bcf52ae9eccbff30da09194b86"}, + {file = "uvloop-0.22.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:481c990a7abe2c6f4fc3d98781cc9426ebd7f03a9aaa7eb03d3bfc68ac2a46bd"}, + {file = "uvloop-0.22.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a592b043a47ad17911add5fbd087c76716d7c9ccc1d64ec9249ceafd735f03c2"}, + {file = "uvloop-0.22.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1489cf791aa7b6e8c8be1c5a080bae3a672791fcb4e9e12249b05862a2ca9cec"}, + {file = "uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9"}, + {file = "uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77"}, + {file = "uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21"}, + {file = "uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702"}, + {file = "uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733"}, + {file = "uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473"}, + {file = "uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42"}, + {file = "uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6"}, + {file = "uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370"}, + {file = "uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4"}, + {file = "uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2"}, + {file = "uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0"}, + {file = "uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705"}, + {file = "uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8"}, + {file = "uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d"}, + {file = "uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e"}, + {file = "uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e"}, + {file = "uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad"}, + {file = "uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142"}, + {file = "uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74"}, + {file = "uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35"}, + {file = "uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25"}, + {file = "uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6"}, + {file = "uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079"}, + {file = "uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289"}, + {file = "uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3"}, + {file = "uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c"}, + {file = "uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21"}, + {file = "uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88"}, + {file = "uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e"}, + {file = "uvloop-0.22.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:80eee091fe128e425177fbd82f8635769e2f32ec9daf6468286ec57ec0313efa"}, + {file = "uvloop-0.22.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:017bd46f9e7b78e81606329d07141d3da446f8798c6baeec124260e22c262772"}, + {file = "uvloop-0.22.1-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3e5c6727a57cb6558592a95019e504f605d1c54eb86463ee9f7a2dbd411c820"}, + {file = "uvloop-0.22.1-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:57df59d8b48feb0e613d9b1f5e57b7532e97cbaf0d61f7aa9aa32221e84bc4b6"}, + {file = "uvloop-0.22.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:55502bc2c653ed2e9692e8c55cb95b397d33f9f2911e929dc97c4d6b26d04242"}, + {file = "uvloop-0.22.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:4a968a72422a097b09042d5fa2c5c590251ad484acf910a651b4b620acd7f193"}, + {file = "uvloop-0.22.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:b45649628d816c030dba3c80f8e2689bab1c89518ed10d426036cdc47874dfc4"}, + {file = "uvloop-0.22.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ea721dd3203b809039fcc2983f14608dae82b212288b346e0bfe46ec2fab0b7c"}, + {file = "uvloop-0.22.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ae676de143db2b2f60a9696d7eca5bb9d0dd6cc3ac3dad59a8ae7e95f9e1b54"}, + {file = "uvloop-0.22.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:17d4e97258b0172dfa107b89aa1eeba3016f4b1974ce85ca3ef6a66b35cbf659"}, + {file = "uvloop-0.22.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:05e4b5f86e621cf3927631789999e697e58f0d2d32675b67d9ca9eb0bca55743"}, + {file = "uvloop-0.22.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:286322a90bea1f9422a470d5d2ad82d38080be0a29c4dd9b3e6384320a4d11e7"}, + {file = "uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f"}, +] + +[package.extras] +dev = ["Cython (>=3.0,<4.0)", "setuptools (>=60)"] +docs = ["Sphinx (>=4.1.2,<4.2.0)", "sphinx_rtd_theme (>=0.5.2,<0.6.0)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)"] +test = ["aiohttp (>=3.10.5)", "flake8 (>=6.1,<7.0)", "mypy (>=0.800)", "psutil", "pyOpenSSL (>=25.3.0,<25.4.0)", "pycodestyle (>=2.11.0,<2.12.0)"] + [[package]] name = "virtualenv" version = "20.36.1" @@ -1655,22 +1796,197 @@ docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "s test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8) ; platform_python_implementation == \"PyPy\" or platform_python_implementation == \"GraalVM\" or platform_python_implementation == \"CPython\" and sys_platform == \"win32\" and python_version >= \"3.13\"", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10) ; platform_python_implementation == \"CPython\""] [[package]] -name = "werkzeug" -version = "3.1.6" -description = "The comprehensive WSGI web application library." +name = "watchfiles" +version = "1.1.1" +description = "Simple, modern and high performance file watching and code reload in python." optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "werkzeug-3.1.6-py3-none-any.whl", hash = "sha256:7ddf3357bb9564e407607f988f683d72038551200c704012bb9a4c523d42f131"}, - {file = "werkzeug-3.1.6.tar.gz", hash = "sha256:210c6bede5a420a913956b4791a7f4d6843a43b6fcee4dfa08a65e93007d0d25"}, + {file = "watchfiles-1.1.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:eef58232d32daf2ac67f42dea51a2c80f0d03379075d44a587051e63cc2e368c"}, + {file = "watchfiles-1.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:03fa0f5237118a0c5e496185cafa92878568b652a2e9a9382a5151b1a0380a43"}, + {file = "watchfiles-1.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8ca65483439f9c791897f7db49202301deb6e15fe9f8fe2fed555bf986d10c31"}, + {file = "watchfiles-1.1.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f0ab1c1af0cb38e3f598244c17919fb1a84d1629cc08355b0074b6d7f53138ac"}, + {file = "watchfiles-1.1.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3bc570d6c01c206c46deb6e935a260be44f186a2f05179f52f7fcd2be086a94d"}, + {file = "watchfiles-1.1.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e84087b432b6ac94778de547e08611266f1f8ffad28c0ee4c82e028b0fc5966d"}, + {file = "watchfiles-1.1.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:620bae625f4cb18427b1bb1a2d9426dc0dd5a5ba74c7c2cdb9de405f7b129863"}, + {file = "watchfiles-1.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:544364b2b51a9b0c7000a4b4b02f90e9423d97fbbf7e06689236443ebcad81ab"}, + {file = "watchfiles-1.1.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:bbe1ef33d45bc71cf21364df962af171f96ecaeca06bd9e3d0b583efb12aec82"}, + {file = "watchfiles-1.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:1a0bb430adb19ef49389e1ad368450193a90038b5b752f4ac089ec6942c4dff4"}, + {file = "watchfiles-1.1.1-cp310-cp310-win32.whl", hash = "sha256:3f6d37644155fb5beca5378feb8c1708d5783145f2a0f1c4d5a061a210254844"}, + {file = "watchfiles-1.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:a36d8efe0f290835fd0f33da35042a1bb5dc0e83cbc092dcf69bce442579e88e"}, + {file = "watchfiles-1.1.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f57b396167a2565a4e8b5e56a5a1c537571733992b226f4f1197d79e94cf0ae5"}, + {file = "watchfiles-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:421e29339983e1bebc281fab40d812742268ad057db4aee8c4d2bce0af43b741"}, + {file = "watchfiles-1.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e43d39a741e972bab5d8100b5cdacf69db64e34eb19b6e9af162bccf63c5cc6"}, + {file = "watchfiles-1.1.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f537afb3276d12814082a2e9b242bdcf416c2e8fd9f799a737990a1dbe906e5b"}, + {file = "watchfiles-1.1.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b2cd9e04277e756a2e2d2543d65d1e2166d6fd4c9b183f8808634fda23f17b14"}, + {file = "watchfiles-1.1.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f3f58818dc0b07f7d9aa7fe9eb1037aecb9700e63e1f6acfed13e9fef648f5d"}, + {file = "watchfiles-1.1.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9bb9f66367023ae783551042d31b1d7fd422e8289eedd91f26754a66f44d5cff"}, + {file = "watchfiles-1.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aebfd0861a83e6c3d1110b78ad54704486555246e542be3e2bb94195eabb2606"}, + {file = "watchfiles-1.1.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5fac835b4ab3c6487b5dbad78c4b3724e26bcc468e886f8ba8cc4306f68f6701"}, + {file = "watchfiles-1.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:399600947b170270e80134ac854e21b3ccdefa11a9529a3decc1327088180f10"}, + {file = "watchfiles-1.1.1-cp311-cp311-win32.whl", hash = "sha256:de6da501c883f58ad50db3a32ad397b09ad29865b5f26f64c24d3e3281685849"}, + {file = "watchfiles-1.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:35c53bd62a0b885bf653ebf6b700d1bf05debb78ad9292cf2a942b23513dc4c4"}, + {file = "watchfiles-1.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:57ca5281a8b5e27593cb7d82c2ac927ad88a96ed406aa446f6344e4328208e9e"}, + {file = "watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d"}, + {file = "watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610"}, + {file = "watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af"}, + {file = "watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6"}, + {file = "watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce"}, + {file = "watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa"}, + {file = "watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb"}, + {file = "watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803"}, + {file = "watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94"}, + {file = "watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43"}, + {file = "watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9"}, + {file = "watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9"}, + {file = "watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404"}, + {file = "watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18"}, + {file = "watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a"}, + {file = "watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219"}, + {file = "watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428"}, + {file = "watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0"}, + {file = "watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150"}, + {file = "watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae"}, + {file = "watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d"}, + {file = "watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b"}, + {file = "watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374"}, + {file = "watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0"}, + {file = "watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42"}, + {file = "watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18"}, + {file = "watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da"}, + {file = "watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051"}, + {file = "watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e"}, + {file = "watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70"}, + {file = "watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261"}, + {file = "watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620"}, + {file = "watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04"}, + {file = "watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77"}, + {file = "watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef"}, + {file = "watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf"}, + {file = "watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5"}, + {file = "watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd"}, + {file = "watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb"}, + {file = "watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5"}, + {file = "watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3"}, + {file = "watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33"}, + {file = "watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510"}, + {file = "watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05"}, + {file = "watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6"}, + {file = "watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81"}, + {file = "watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b"}, + {file = "watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a"}, + {file = "watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02"}, + {file = "watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21"}, + {file = "watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5"}, + {file = "watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7"}, + {file = "watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101"}, + {file = "watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44"}, + {file = "watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c"}, + {file = "watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc"}, + {file = "watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c"}, + {file = "watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099"}, + {file = "watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01"}, + {file = "watchfiles-1.1.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:c882d69f6903ef6092bedfb7be973d9319940d56b8427ab9187d1ecd73438a70"}, + {file = "watchfiles-1.1.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d6ff426a7cb54f310d51bfe83fe9f2bbe40d540c741dc974ebc30e6aa238f52e"}, + {file = "watchfiles-1.1.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:79ff6c6eadf2e3fc0d7786331362e6ef1e51125892c75f1004bd6b52155fb956"}, + {file = "watchfiles-1.1.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c1f5210f1b8fc91ead1283c6fd89f70e76fb07283ec738056cf34d51e9c1d62c"}, + {file = "watchfiles-1.1.1-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9c4702f29ca48e023ffd9b7ff6b822acdf47cb1ff44cb490a3f1d5ec8987e9c"}, + {file = "watchfiles-1.1.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:acb08650863767cbc58bca4813b92df4d6c648459dcaa3d4155681962b2aa2d3"}, + {file = "watchfiles-1.1.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:08af70fd77eee58549cd69c25055dc344f918d992ff626068242259f98d598a2"}, + {file = "watchfiles-1.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c3631058c37e4a0ec440bf583bc53cdbd13e5661bb6f465bc1d88ee9a0a4d02"}, + {file = "watchfiles-1.1.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:cf57a27fb986c6243d2ee78392c503826056ffe0287e8794503b10fb51b881be"}, + {file = "watchfiles-1.1.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d7e7067c98040d646982daa1f37a33d3544138ea155536c2e0e63e07ff8a7e0f"}, + {file = "watchfiles-1.1.1-cp39-cp39-win32.whl", hash = "sha256:6c9c9262f454d1c4d8aaa7050121eb4f3aea197360553699520767daebf2180b"}, + {file = "watchfiles-1.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:74472234c8370669850e1c312490f6026d132ca2d396abfad8830b4f1c096957"}, + {file = "watchfiles-1.1.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:17ef139237dfced9da49fb7f2232c86ca9421f666d78c264c7ffca6601d154c3"}, + {file = "watchfiles-1.1.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:672b8adf25b1a0d35c96b5888b7b18699d27d4194bac8beeae75be4b7a3fc9b2"}, + {file = "watchfiles-1.1.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77a13aea58bc2b90173bc69f2a90de8e282648939a00a602e1dc4ee23e26b66d"}, + {file = "watchfiles-1.1.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b495de0bb386df6a12b18335a0285dda90260f51bdb505503c02bcd1ce27a8b"}, + {file = "watchfiles-1.1.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:db476ab59b6765134de1d4fe96a1a9c96ddf091683599be0f26147ea1b2e4b88"}, + {file = "watchfiles-1.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:89eef07eee5e9d1fda06e38822ad167a044153457e6fd997f8a858ab7564a336"}, + {file = "watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce19e06cbda693e9e7686358af9cd6f5d61312ab8b00488bc36f5aabbaf77e24"}, + {file = "watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e6f39af2eab0118338902798b5aa6664f46ff66bc0280de76fca67a7f262a49"}, + {file = "watchfiles-1.1.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:cdab464fee731e0884c35ae3588514a9bcf718d0e2c82169c1c4a85cc19c3c7f"}, + {file = "watchfiles-1.1.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:3dbd8cbadd46984f802f6d479b7e3afa86c42d13e8f0f322d669d79722c8ec34"}, + {file = "watchfiles-1.1.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5524298e3827105b61951a29c3512deb9578586abf3a7c5da4a8069df247cccc"}, + {file = "watchfiles-1.1.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b943d3668d61cfa528eb949577479d3b077fd25fb83c641235437bc0b5bc60e"}, + {file = "watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2"}, ] [package.dependencies] -markupsafe = ">=2.1.1" +anyio = ">=3.0.0" -[package.extras] -watchdog = ["watchdog (>=2.3)"] +[[package]] +name = "websockets" +version = "16.0" +description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "websockets-16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:04cdd5d2d1dacbad0a7bf36ccbcd3ccd5a30ee188f2560b7a62a30d14107b31a"}, + {file = "websockets-16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8ff32bb86522a9e5e31439a58addbb0166f0204d64066fb955265c4e214160f0"}, + {file = "websockets-16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:583b7c42688636f930688d712885cf1531326ee05effd982028212ccc13e5957"}, + {file = "websockets-16.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7d837379b647c0c4c2355c2499723f82f1635fd2c26510e1f587d89bc2199e72"}, + {file = "websockets-16.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df57afc692e517a85e65b72e165356ed1df12386ecb879ad5693be08fac65dde"}, + {file = "websockets-16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2b9f1e0d69bc60a4a87349d50c09a037a2607918746f07de04df9e43252c77a3"}, + {file = "websockets-16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:335c23addf3d5e6a8633f9f8eda77efad001671e80b95c491dd0924587ece0b3"}, + {file = "websockets-16.0-cp310-cp310-win32.whl", hash = "sha256:37b31c1623c6605e4c00d466c9d633f9b812ea430c11c8a278774a1fde1acfa9"}, + {file = "websockets-16.0-cp310-cp310-win_amd64.whl", hash = "sha256:8e1dab317b6e77424356e11e99a432b7cb2f3ec8c5ab4dabbcee6add48f72b35"}, + {file = "websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8"}, + {file = "websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad"}, + {file = "websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d"}, + {file = "websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe"}, + {file = "websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b"}, + {file = "websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5"}, + {file = "websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64"}, + {file = "websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6"}, + {file = "websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac"}, + {file = "websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00"}, + {file = "websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79"}, + {file = "websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39"}, + {file = "websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c"}, + {file = "websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f"}, + {file = "websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1"}, + {file = "websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2"}, + {file = "websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89"}, + {file = "websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea"}, + {file = "websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9"}, + {file = "websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230"}, + {file = "websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c"}, + {file = "websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5"}, + {file = "websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82"}, + {file = "websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8"}, + {file = "websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f"}, + {file = "websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a"}, + {file = "websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156"}, + {file = "websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0"}, + {file = "websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904"}, + {file = "websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4"}, + {file = "websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e"}, + {file = "websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4"}, + {file = "websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1"}, + {file = "websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3"}, + {file = "websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8"}, + {file = "websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d"}, + {file = "websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244"}, + {file = "websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e"}, + {file = "websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641"}, + {file = "websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8"}, + {file = "websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e"}, + {file = "websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944"}, + {file = "websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206"}, + {file = "websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6"}, + {file = "websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd"}, + {file = "websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d"}, + {file = "websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03"}, + {file = "websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da"}, + {file = "websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c"}, + {file = "websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767"}, + {file = "websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec"}, + {file = "websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5"}, +] [[package]] name = "wrapt" @@ -1779,4 +2095,4 @@ platformdirs = ">=3.5.1" [metadata] lock-version = "2.1" python-versions = "^3.11" -content-hash = "f826d021f8e0c57c0c61f13cda4ff141c4ff2ecd0c34b79c1ab0c1a8615d51b1" +content-hash = "2866f13d58c89fd97be26e01c301bf5da6167c40da3f0e194df094bae749f0d9" diff --git a/pyproject.toml b/pyproject.toml index 30b0a836..0be8f8d2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,12 +10,9 @@ dynamic = ["dependencies"] [tool.poetry.dependencies] python = "^3.11" -Flask = "^3.1.3" -gunicorn = "^23.0.0" mongoengine = "^0.29.1" blinker = "^1.5" PyJWT = "^2.12" -requests = "^2.33" mosspy = "^1.0" redis = "^4.4" # <4.9.0 to fix mongomock GridFS integration @@ -24,6 +21,10 @@ pymongo = "^4.5.0,<4.9.0" minio = "^7.2.15" python-ulid = "^3.0.0" pydantic = "^2.0" +fastapi = "^0.115" +uvicorn = {version = "^0.30", extras = ["standard"]} +python-multipart = "^0.0.9" +httpx = "^0.27" [tool.poetry] requires-poetry = ">=2.0" diff --git a/tests/base_tester.py b/tests/base_tester.py index 786be074..6dbd77b9 100644 --- a/tests/base_tester.py +++ b/tests/base_tester.py @@ -1,25 +1,21 @@ import secrets -import typing from typing import Literal, Tuple, Dict, Any, Union import mongomock from mongoengine import connect from mongo import * -from flask.testing import FlaskClient +from starlette.testclient import TestClient from .conftest import * -if typing.TYPE_CHECKING: - from flask.testing import TestResponse - def random_string(k=None): ''' - return a random string + return a random string Args: k: the return string's byte length, if None, - then use the `secrets` module's default - value. notice that the byte length will + then use the `secrets` module's default + value. notice that the byte length will not equal string length Returns: @@ -81,14 +77,17 @@ def add_user(cls, username, role=2): @staticmethod def request( - client: FlaskClient, + client: TestClient, method: Literal['get', 'post', 'put', 'patch', 'delete'], url: str, **ks, - ) -> Tuple['TestResponse', Union[Any, Dict[str, Any]], Union[Any, None]]: + ) -> Tuple[Any, Union[Any, Dict[str, Any]], Union[Any, None]]: func = getattr(client, method) - rv: 'TestResponse' = func(url, **ks) - rv_json = rv.get_json() + rv = func(url, **ks) + try: + rv_json = rv.json() + except Exception: + rv_json = None if isinstance(rv_json, dict): rv_data = rv_json.get('data') else: diff --git a/tests/conftest.py b/tests/conftest.py index 3b4e2ec7..501af86b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,8 @@ +import os from typing import Dict, List, Protocol -from flask import Flask -from flask.testing import FlaskClient +from starlette.testclient import TestClient +import httpx + from mongo import * from mongo import engine import mongomock.gridfs @@ -34,38 +36,41 @@ def setup_minio(): @pytest.fixture def app(tmp_path): - from app import app as flask_app - app = flask_app() - app.config['TESTING'] = True - app.config['SERVER_NAME'] = 'test.test' + os.environ['TESTING'] = '1' + from app import app as fastapi_app, _seed_db mongomock.gridfs.enable_gridfs_integration() + # Re-run seed in case a prior setup_class dropped the DB + _seed_db() + # modify submission config for testing # use tmp dir to save user source code submission_tmp_dir = (tmp_path / Submission.config().TMP_DIR).absolute() submission_tmp_dir.mkdir(exist_ok=True) Submission.config().TMP_DIR = submission_tmp_dir - return app + return fastapi_app # TODO: share client may cause auth problem @pytest.fixture -def client(app: Flask): - return app.test_client() +def client(app): + with TestClient(app, raise_server_exceptions=False, + follow_redirects=False) as c: + yield c class ForgeClient(Protocol): - def __call__(self, username: str) -> FlaskClient: + def __call__(self, username: str) -> TestClient: ... @pytest.fixture -def forge_client(client: FlaskClient): +def forge_client(client: TestClient): - def seted_cookie(username: str) -> FlaskClient: - client.set_cookie('piann', User(username).secret, domain='test.test') + def seted_cookie(username: str) -> TestClient: + client.cookies.set('piann', User(username).secret) return client return seted_cookie @@ -138,9 +143,9 @@ def make_course(username, students={}, tas=[]): 'studentNicknames': c_data.students }, ) - assert rv.status_code == 200, rv.get_json() + assert rv.status_code == 200, rv.json() - client._cookies.clear() + client.cookies.clear() return c_data return make_course @@ -193,7 +198,7 @@ def problem_ids( }, ) if prob.problem_type != 2: - test_case = get_file('default/test_case.zip')['case'][0] + test_case = get_file('default/test_case.zip')['case'][1] prob.update_test_case(test_case) rets.append(prob.id) @@ -272,20 +277,19 @@ def submit_once(name, pid, filename, lang, client=None): filename: source code's zip filename lang: language ID ''' - with app.app_context(): - now = datetime.now() - try: - submission = Submission.add( - problem_id=pid, - username=name, - lang=lang, - timestamp=now, - ip_addr="127.0.0.1", - ) - except engine.DoesNotExist as e: - assert False, str(e) - res = submission.submit(get_source(filename)) - assert res == True + now = datetime.now() + try: + submission = Submission.add( + problem_id=pid, + username=name, + lang=lang, + timestamp=now, + ip_addr="127.0.0.1", + ) + except engine.DoesNotExist as e: + assert False, str(e) + res = submission.submit(get_source(filename)) + assert res == True return submission.id return submit_once diff --git a/tests/test_auth.py b/tests/test_auth.py index d9cbce1d..19895a63 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -18,7 +18,7 @@ class TestSignup: def test_without_username_and_email(self, client): # Signup without username and password rv = client.post('/auth/signup', json={'password': 'test'}) - json = rv.get_json() + json = rv.json() assert rv.status_code == 400 assert json['status'] == 'err' assert json['message'] == 'Invalid request body' @@ -30,7 +30,7 @@ def test_empty_password(self, client): 'username': 'test', 'email': 'test@test.test' }) - json = rv.get_json() + json = rv.json() assert rv.status_code == 400 assert json['status'] == 'err' assert json['message'] == 'Invalid request body' @@ -44,8 +44,8 @@ def test_too_long_username(self, client): 'password': password, 'email': f'{name}@noj.tw', }) - assert rv.status_code == 400, rv.get_json() - assert rv.get_json()['message'] == 'Signup Failed' + assert rv.status_code == 400, rv.json() + assert rv.json()['message'] == 'Signup Failed' def test_invalid_username(self, client): name = secrets.token_hex()[:12] @@ -56,8 +56,8 @@ def test_invalid_username(self, client): 'password': password, 'email': f'{name}@noj.tw', }) - assert rv.status_code == 400, rv.get_json() - assert rv.get_json()['message'] == 'Not Allowed Name' + assert rv.status_code == 400, rv.json() + assert rv.json()['message'] == 'Not Allowed Name' def test_signup(self, client, forge_client): # Signup @@ -67,7 +67,7 @@ def test_signup(self, client, forge_client): 'password': 'test', 'email': 'test@test.test' }) - json = rv.get_json() + json = rv.json() assert rv.status_code == 200 assert json['status'] == 'ok' assert json['message'] == 'Signup Success' @@ -80,8 +80,8 @@ def test_signup(self, client, forge_client): }) client = forge_client('test2') rv = client.get('/auth/me') - assert rv.status_code == 403, rv.get_json() - assert rv.get_json()['message'] == 'Inactive User' + assert rv.status_code == 403, rv.json() + assert rv.json()['message'] == 'Inactive User' def test_used_username(self, client): # Signup with used username @@ -91,7 +91,7 @@ def test_used_username(self, client): 'password': 'test', 'email': 'test@test.test' }) - json = rv.get_json() + json = rv.json() assert rv.status_code == 400 assert json['status'] == 'err' assert json['message'] == 'User Exists' @@ -104,17 +104,13 @@ def test_used_email(self, client): 'password': 'test', 'email': 'test@test.test' }) - json = rv.get_json() + json = rv.json() assert rv.status_code == 400 assert json['status'] == 'err' assert json['message'] == 'User Exists' def test_directly_add_user_by_admin(self, client): - client.set_cookie( - 'piann', - User('first_admin').secret, - domain='test.test', - ) + client.cookies.set('piann', User('first_admin').secret) name = secrets.token_hex()[:12] assert not User(name), name password = secrets.token_hex() @@ -126,8 +122,8 @@ def test_directly_add_user_by_admin(self, client): 'email': f'{name}@noj.tw', }, ) - assert rv.status_code == 200, rv.get_json() - client.delete_cookie('piann', domain='test.test') + assert rv.status_code == 200, rv.json() + del client.cookies['piann'] rv = client.post( '/auth/session', json={ @@ -135,7 +131,7 @@ def test_directly_add_user_by_admin(self, client): 'password': password, }, ) - assert rv.status_code == 200, rv.get_json() + assert rv.status_code == 200, rv.json() def test_add_user_with_invalid_username(self, forge_client): client = forge_client('first_admin') @@ -147,8 +143,8 @@ def test_add_user_with_invalid_username(self, forge_client): 'password': password, 'email': f'{name}@noj.tw', }) - assert rv.status_code == 400, rv.get_json() - assert rv.get_json()['message'] == 'Not Allowed Name' + assert rv.status_code == 400, rv.json() + assert rv.json()['message'] == 'Not Allowed Name' def test_add_user_with_too_long_username(self, forge_client): client = forge_client('first_admin') @@ -160,9 +156,9 @@ def test_add_user_with_too_long_username(self, forge_client): 'password': password, 'email': f'{name}@noj.tw', }) - assert rv.status_code == 400, rv.get_json() - assert rv.get_json()['message'] == 'Signup Failed' - assert rv.get_json()['data']['username'] == 'String value is too long' + assert rv.status_code == 400, rv.json() + assert rv.json()['message'] == 'Signup Failed' + assert rv.json()['data']['username'] == 'String value is too long' def test_add_user_with_existent_user(self, forge_client): client = forge_client('first_admin') @@ -174,15 +170,15 @@ def test_add_user_with_existent_user(self, forge_client): 'password': password, 'email': f'{name}@noj.tw', }) - assert rv.status_code == 200, rv.get_json() + assert rv.status_code == 200, rv.json() rv = client.post('/auth/user', json={ 'username': name, 'password': password, 'email': f'{name}@noj.tw', }) - assert rv.status_code == 400, rv.get_json() - assert rv.get_json()['message'] == 'User Exists' + assert rv.status_code == 400, rv.json() + assert rv.json()['message'] == 'User Exists' @pytest.mark.parametrize('username', ('teacher', 'student')) def test_non_admin_cannot_add_user(self, forge_client, username: str): @@ -195,7 +191,7 @@ def test_non_admin_cannot_add_user(self, forge_client, username: str): 'email': secrets.token_hex()[:12] + '@noj.tw', }, ) - assert rv.status_code == 403, rv.get_json() + assert rv.status_code == 403, rv.json() class TestActive: @@ -205,7 +201,7 @@ class TestActive: def test_redirect_with_invalid_toke(self, client): # Access active-page with invalid token rv = client.get('/auth/active/invalid_token') - json = rv.get_json() + json = rv.json() assert rv.status_code == 403 assert json['status'] == 'err' assert json['message'] == 'Invalid Token' @@ -213,7 +209,6 @@ def test_redirect_with_invalid_toke(self, client): def test_redirect(self, client, test_token): # Redirect to active-page rv = client.get(f'/auth/active/{test_token}') - json = rv.get_json() assert rv.status_code == 302 def test_update_with_invalid_data(self, client): @@ -223,7 +218,7 @@ def test_update_with_invalid_data(self, client): json={ 'profile': 123 # profile should be a dictionary }) - json = rv.get_json() + json = rv.json() assert rv.status_code == 400 assert json['status'] == 'err' assert json['message'] == 'Invalid request body' @@ -235,7 +230,7 @@ def test_update_without_agreement(self, client): 'profile': {}, 'agreement': 123 }) - json = rv.get_json() + json = rv.json() assert rv.status_code == 400 assert json['status'] == 'err' assert json['message'] == 'Invalid request body' @@ -246,8 +241,8 @@ def test_update_without_true_agreement(self, client): 'profile': {}, 'agreement': False }) - assert rv.status_code == 403, rv.get_json() - assert rv.get_json()['message'] == 'Not Confirm the Agreement' + assert rv.status_code == 403, rv.json() + assert rv.json()['message'] == 'Not Confirm the Agreement' def test_update_with_invalid_token(self, client): rv = client.post(f'/auth/active', @@ -255,8 +250,8 @@ def test_update_with_invalid_token(self, client): 'profile': {}, 'agreement': True }) - assert rv.status_code == 403, rv.get_json() - assert rv.get_json()['message'] == 'Invalid Token.' + assert rv.status_code == 403, rv.json() + assert rv.json()['message'] == 'Invalid Token.' def test_update_with_user_not_exists(self, client, monkeypatch): from model import auth @@ -275,8 +270,8 @@ def mock_jwt_decode(_): 'profile': {}, 'agreement': True }) - assert rv.status_code == 400, rv.get_json() - assert rv.get_json()['message'] == 'User Not Exists' + assert rv.status_code == 400, rv.json() + assert rv.json()['message'] == 'User Not Exists' def test_update_public_course_not_exists(self, client, test_token, monkeypatch): @@ -285,18 +280,18 @@ def raise_public_course_not_exists(*args, **kwargs): raise engine.DoesNotExist('Public Course Not Exists') monkeypatch.setattr(User, 'activate', raise_public_course_not_exists) - client.set_cookie('piann', test_token, domain='test.test') + client.cookies.set('piann', test_token) rv = client.post(f'/auth/active', json={ 'profile': {}, 'agreement': True }) - assert rv.status_code == 404, rv.get_json() - assert rv.get_json()['message'] == 'Public Course Not Exists' + assert rv.status_code == 404, rv.json() + assert rv.json()['message'] == 'Public Course Not Exists' def test_update(self, client, test_token): # Update - client.set_cookie('piann', test_token, domain='test.test') + client.cookies.set('piann', test_token) rv = client.post( f'/auth/active', json={ @@ -307,20 +302,20 @@ def test_update(self, client, test_token): 'agreement': True }, ) - json = rv.get_json() + json = rv.json() assert rv.status_code == 200 assert json['status'] == 'ok' assert json['message'] == 'User Is Now Active' def test_update_with_activated_user(self, client, test_token): - client.set_cookie('piann', test_token, domain='test.test') + client.cookies.set('piann', test_token) rv = client.post(f'/auth/active', json={ 'profile': {}, 'agreement': True }) - assert rv.status_code == 400, rv.get_json() - assert rv.get_json()['message'] == 'User Has Been Actived' + assert rv.status_code == 400, rv.json() + assert rv.json()['message'] == 'User Has Been Actived' @pytest.mark.parametrize( 'role', @@ -353,16 +348,16 @@ def test_recovery_with_user_not_exists(self, client): json={ 'email': f'{name}_not_exists@test.test', }) - assert rv.status_code == 400, rv.get_json() - assert rv.get_json()['message'] == 'User Not Exists' + assert rv.status_code == 400, rv.json() + assert rv.json()['message'] == 'User Not Exists' def test_recovery(self, client): rv = client.post('/auth/password-recovery', json={ 'email': 'test@test.test', }) - assert rv.status_code == 200, rv.get_json() - assert rv.get_json()['message'] == 'Recovery Email Has Been Sent' + assert rv.status_code == 200, rv.json() + assert rv.json()['message'] == 'Recovery Email Has Been Sent' test_user = User('test') assert test_user.user_id != test_user.user_id2 @@ -371,35 +366,35 @@ class TestCheckUser: def test_name_exists(self, client): rv = client.post('/auth/check/username', json={'username': 'test'}) - assert rv.status_code == 200, rv.get_json() - assert rv.get_json()['message'] == 'User Exists' - assert rv.get_json()['data']['valid'] == 0 + assert rv.status_code == 200, rv.json() + assert rv.json()['message'] == 'User Exists' + assert rv.json()['data']['valid'] == 0 def test_name_not_exist(self, client): name = secrets.token_hex()[:12] rv = client.post('/auth/check/username', json={'username': name}) - assert rv.status_code == 200, rv.get_json() - assert rv.get_json()['message'] == 'Username Can Be Used' - assert rv.get_json()['data']['valid'] == 1 + assert rv.status_code == 200, rv.json() + assert rv.json()['message'] == 'Username Can Be Used' + assert rv.json()['data']['valid'] == 1 def test_email_exists(self, client): rv = client.post('/auth/check/email', json={'email': 'test@test.test'}) - assert rv.status_code == 200, rv.get_json() - assert rv.get_json()['message'] == 'Email Has Been Used' - assert rv.get_json()['data']['valid'] == 0 + assert rv.status_code == 200, rv.json() + assert rv.json()['message'] == 'Email Has Been Used' + assert rv.json()['data']['valid'] == 0 def test_email_not_exist(self, client): name = secrets.token_hex()[:12] rv = client.post('/auth/check/email', json={'email': f'{name}@test.test'}) - assert rv.status_code == 200, rv.get_json() - assert rv.get_json()['message'] == 'Email Can Be Used' - assert rv.get_json()['data']['valid'] == 1 + assert rv.status_code == 200, rv.json() + assert rv.json()['message'] == 'Email Can Be Used' + assert rv.json()['data']['valid'] == 1 def test_invalid_type(self, client): rv = client.post('/auth/check/invalid') - assert rv.status_code == 400, rv.get_json() - assert rv.get_json()['message'] == 'Ivalid Checking Type' + assert rv.status_code == 400, rv.json() + assert rv.json()['message'] == 'Ivalid Checking Type' class TestResendEmail: @@ -408,8 +403,8 @@ def test_user_not_exists(self, client): name = secrets.token_hex()[:12] rv = client.post('/auth/resend-email', json={'email': f'{name}@test.test'}) - assert rv.status_code == 400, rv.get_json() - assert rv.get_json()['message'] == 'User Not Exists' + assert rv.status_code == 400, rv.json() + assert rv.json()['message'] == 'User Not Exists' def test_user_has_been_actived(self, forge_client): client = forge_client('first_admin') @@ -422,17 +417,17 @@ def test_user_has_been_actived(self, forge_client): 'email': f'{name}@test.test', }) assert rv.status_code == 200 - client.delete_cookie('pinna', domain='test.test') + del client.cookies['pinna'] rv = client.post('/auth/resend-email', json={'email': f'{name}@test.test'}) - assert rv.status_code == 400, rv.get_json() - assert rv.get_json()['message'] == 'User Has Been Actived' + assert rv.status_code == 400, rv.json() + assert rv.json()['message'] == 'User Has Been Actived' def test_normal_resend(self, client): rv = client.post('/auth/resend-email', json={'email': 'test2@test.test'}) - assert rv.status_code == 200, rv.get_json() - assert rv.get_json()['message'] == 'Email Has Been Resent' + assert rv.status_code == 200, rv.json() + assert rv.json()['message'] == 'Email Has Been Resent' class TestLogin: @@ -442,7 +437,7 @@ class TestLogin: def test_incomplete_data(self, client): # Login with incomplete data rv = client.post('/auth/session', json={}) - json = rv.get_json() + json = rv.json() assert rv.status_code == 400 assert json['status'] == 'err' assert json['message'] == 'Invalid request body' @@ -454,7 +449,7 @@ def test_wrong_password(self, client): 'username': 'test', 'password': 'tset' }) - json = rv.get_json() + json = rv.json() assert rv.status_code == 403 assert json['status'] == 'err' assert json['message'] == 'Login Failed' @@ -466,7 +461,7 @@ def test_not_active(self, client): 'username': 'test2', 'password': 'test2' }) - json = rv.get_json() + json = rv.json() assert rv.status_code == 403 assert json['status'] == 'err' assert json['message'] == 'Invalid User' @@ -478,7 +473,7 @@ def test_with_username(self, client): 'username': 'test', 'password': 'test' }) - json = rv.get_json() + json = rv.json() assert rv.status_code == 200 assert json['status'] == 'ok' assert json['message'] == 'Login Success' @@ -490,7 +485,7 @@ def test_with_email(self, client): 'username': 'test@test.test', 'password': 'test' }) - json = rv.get_json() + json = rv.json() assert rv.status_code == 200 assert json['status'] == 'ok' assert json['message'] == 'Login Success' @@ -502,9 +497,9 @@ class TestLogout: def test_logout(self, client, test_token): # Logout - client.set_cookie('piann', test_token, domain='test.test') + client.cookies.set('piann', test_token) rv = client.get('/auth/session') - json = rv.get_json() + json = rv.json() assert rv.status_code == 200 assert json['status'] == 'ok' assert json['message'] == 'Goodbye' @@ -514,18 +509,18 @@ def test_get_self_data(client): rv = client.get('/auth/me') assert rv.status_code == 403 test_user = User('test') - client.set_cookie('piann', test_user.secret, domain='test.test') + client.cookies.set('piann', test_user.secret) rv = client.get( '/auth/me', - query_string='fields=username,displayedName', + params='fields=username,displayedName', ) - assert rv.status_code == 200, rv.get_json() - rv_data = rv.get_json()['data'] + assert rv.status_code == 200, rv.json() + rv_data = rv.json()['data'] assert rv_data['username'] == test_user.username assert rv_data['displayedName'] == test_user.profile.displayed_name rv = client.get('/auth/me') - assert rv.status_code == 200, rv.get_json() - rv_data = rv.get_json()['data'] + assert rv.status_code == 200, rv.json() + rv_data = rv.json()['data'] assert rv_data['username'] == test_user.username assert rv_data['displayedName'] == test_user.profile.displayed_name @@ -550,8 +545,8 @@ def test_identity_verify(forge_client): 'email': f'{name}@noj.tw', }, ) - assert rv.status_code == 403, rv.get_json() - assert rv.get_json()['message'] == 'Insufficient Permissions' + assert rv.status_code == 403, rv.json() + assert rv.json()['message'] == 'Insufficient Permissions' class TestChangePassword: @@ -575,12 +570,13 @@ def test_change_password(self, forge_client): 'oldPassword': password, 'newPassword': new_password }) - assert rv.status_code == 200, rv.get_json() - assert rv.get_json()['message'] == 'Password Has Been Changed' - client.set_cookie('piann', old_secret, domain='test.test') + assert rv.status_code == 200, rv.json() + assert rv.json()['message'] == 'Password Has Been Changed' + client.cookies.clear() + client.cookies.set('piann', old_secret) rv = client.get('/auth/me') - assert rv.status_code == 403, rv.get_json() - assert rv.get_json()['message'] == 'Authorization Expired' + assert rv.status_code == 403, rv.json() + assert rv.json()['message'] == 'Authorization Expired' def test_change_password_with_wrong_password(self, forge_client): client = forge_client('first_admin') @@ -601,8 +597,8 @@ def test_change_password_with_wrong_password(self, forge_client): 'oldPassword': bad_password, 'newPassword': new_password }) - assert rv.status_code == 403, rv.get_json() - assert rv.get_json()['message'] == 'Wrong Password' + assert rv.status_code == 403, rv.json() + assert rv.json()['message'] == 'Wrong Password' class TestBatchSignup: @@ -674,7 +670,7 @@ def test_normally_register(self, forge_client): 'newUsers': self.convert_to_csv(excepted_users), }, ) - assert rv.status_code == 200, rv.get_json() + assert rv.status_code == 200, rv.json() # Ensure the users has been registered for u in excepted_users: login = User.login(u.username, u.password, "127.0.0.1") @@ -696,8 +692,8 @@ def csv_raise_error(*args, **kwargs): 'This should raise csv.Error\n', }, ) - assert rv.status_code == 400, rv.get_json() - assert rv.get_json()['message'] == 'Invalid file content' + assert rv.status_code == 400, rv.json() + assert rv.json()['message'] == 'Invalid file content' def test_signup_with_course(self, forge_client): course_name = secrets.token_urlsafe(10) @@ -711,7 +707,7 @@ def test_signup_with_course(self, forge_client): 'course': course_name, }, ) - assert rv.status_code == 200, rv.get_json() + assert rv.status_code == 200, rv.json() course = Course(course_name) for u in excepted_users: assert u.username in course.student_nicknames @@ -735,7 +731,7 @@ def test_signup_with_existent_user(self, forge_client): 'course': course_name, }, ) - assert rv.status_code == 200, rv.get_json() + assert rv.status_code == 200, rv.json() course = Course(course_name) for u in excepted_users: login = User.login(u.username, u.password, "127.0.0.1") @@ -753,7 +749,7 @@ def test_signup_with_displayed_name(self, forge_client): 'newUsers': self.convert_to_csv(excepted_users), }, ) - assert rv.status_code == 200, rv.get_json() + assert rv.status_code == 200, rv.json() for u in excepted_users: login = User.login(u.username, u.password, "127.0.0.1") assert login == User.get_by_username(u.username) @@ -770,7 +766,7 @@ def test_signup_with_role(self, forge_client): 'newUsers': self.convert_to_csv(excepted_users), }, ) - assert rv.status_code == 200, rv.get_json() + assert rv.status_code == 200, rv.json() for u in excepted_users: login = User.login(u.username, u.password, "127.0.0.1") assert login == User.get_by_username(u.username) @@ -785,7 +781,7 @@ def test_signup_without_optional_field(self, forge_client): 'newUsers': 'username,password,email\n' + except_user.row(), }, ) - assert rv.status_code == 200, rv.get_json() + assert rv.status_code == 200, rv.json() login = User.login(except_user.username, except_user.password, "127.0.0.1") assert login == User.get_by_username(except_user.username) @@ -800,8 +796,8 @@ def test_signup_with_invalid_input_format(self, forge_client): 'This should not register any user\n', }, ) - assert rv.status_code == 400, rv.get_json() - assert 'input' in rv.get_json()['message'] + assert rv.status_code == 400, rv.json() + assert 'input' in rv.json()['message'] def test_signup_with_invalid_role(self, forge_client): client = forge_client('first_admin') @@ -813,9 +809,9 @@ def test_signup_with_invalid_role(self, forge_client): 'fakeuser,1234,fake@n0j.tw,a\n' }, ) - assert rv.status_code == 400, rv.get_json() - assert 'username' in rv.get_json()['message'] - assert 'role' in rv.get_json()['message'] + assert rv.status_code == 400, rv.json() + assert 'username' in rv.json()['message'] + assert 'role' in rv.json()['message'] def test_signup_with_used_email(self, forge_client): client = forge_client('first_admin') @@ -827,7 +823,7 @@ def test_signup_with_used_email(self, forge_client): 'fakeuser,1234,i.am.first.admin@noj.tw\n' }, ) - assert rv.status_code == 200, rv.get_json() + assert rv.status_code == 200, rv.json() def test_force_signup_should_override_existent_users( self, @@ -868,7 +864,7 @@ def test_force_signup_should_override_existent_users( 'force': True, }, ) - assert rv.status_code == 200, rv.get_json() + assert rv.status_code == 200, rv.json() course.reload() for u in excepted_users: @@ -879,19 +875,26 @@ def test_force_signup_should_override_existent_users( def test_get_me_with_invalid_field(self, forge_client): client = forge_client('first_admin') rv = client.get('/auth/me?fields=invalid') - assert rv.status_code == 400, rv.get_json() + assert rv.status_code == 400, rv.json() +@pytest.mark.skip( + reason= + 'Flask-specific SERVER_NAME/APPLICATION_ROOT config; not applicable to FastAPI' +) def test_verify_link_without_subdirectory(app): server_name = '4pi.n0j.tw' app.config['SERVER_NAME'] = server_name u = utils.user.create_user() expected_url = f'https://{server_name}/auth/active/{u.cookie}' - with app.app_context(): - assert expected_url == get_verify_link(u) + assert expected_url == get_verify_link(u) +@pytest.mark.skip( + reason= + 'Flask-specific SERVER_NAME/APPLICATION_ROOT config; not applicable to FastAPI' +) def test_verify_link_with_subdirectory(app): server_name = 'n0j.tw' subdirectory = '/4pi' @@ -900,8 +903,7 @@ def test_verify_link_with_subdirectory(app): u = utils.user.create_user() expected_url = f'https://{server_name}{subdirectory}/auth/active/{u.cookie}' - with app.app_context(): - assert expected_url == get_verify_link(u) + assert expected_url == get_verify_link(u) def test_login_recorded_after_login(client): diff --git a/tests/test_copycat.py b/tests/test_copycat.py index b1dd9bdf..af15a3f9 100644 --- a/tests/test_copycat.py +++ b/tests/test_copycat.py @@ -71,38 +71,38 @@ def test_copycat(self, forge_client, problem_ids, make_course, submit_once, def test_get_report_without_arguments(self, client_teacher): rv = client_teacher.get('/copycat') - assert rv.status_code == 400, rv.get_json() - assert rv.get_json( + assert rv.status_code == 400, rv.json() + assert rv.json( )['message'] == 'missing arguments! (In HTTP GET argument format)' def test_get_report_with_invalid_problem_id(self, client_admin): rv = client_admin.get('/copycat?course=Public&problemId=bbb') - assert rv.status_code == 400, rv.get_json() - assert rv.get_json()['message'] == 'problemId must be integer' + assert rv.status_code == 400, rv.json() + assert rv.json()['message'] == 'problemId must be integer' def test_get_report_without_perm(self, client_student): rv = client_student.get('/copycat?course=Public&problemId=123') - assert rv.status_code == 403, rv.get_json() - assert rv.get_json()['message'] == 'Forbidden.' + assert rv.status_code == 403, rv.json() + assert rv.json()['message'] == 'Forbidden.' def test_get_report_with_problem_does_not_exist(self, client_admin): rv = client_admin.get('/copycat?course=Public&problemId=87878787') - assert rv.status_code == 404, rv.get_json() - assert rv.get_json()['message'] == 'Problem not exist.' + assert rv.status_code == 404, rv.json() + assert rv.json()['message'] == 'Problem not exist.' def test_get_report_with_course_does_not_exist(self, client_admin, problem_ids): pid = problem_ids("teacher", 1, True)[0] rv = client_admin.get( f'/copycat?course=CourseDoesNotExist&problemId={pid}') - assert rv.status_code == 404, rv.get_json() - assert rv.get_json()['message'] == 'Course not found.' + assert rv.status_code == 404, rv.json() + assert rv.json()['message'] == 'Course not found.' def test_get_report_before_request(self, client_admin, problem_ids): pid = problem_ids("teacher", 1, True)[0] rv = client_admin.get(f'/copycat?course=Public&problemId={pid}') - assert rv.status_code == 404, rv.get_json() - assert rv.get_json( + assert rv.status_code == 404, rv.json() + assert rv.json( )['message'] == 'No report found. Please make a post request to copycat api to generate a report' def test_get_report(self, client_admin, problem_ids, monkeypatch): @@ -118,17 +118,16 @@ def mock_get_report_by_url(_, count=[]): problem = Problem(pid) problem.update(moss_status=2) rv = client_admin.get(f'/copycat?course=Public&problemId={pid}') - assert rv.status_code == 200, rv.get_json() - assert rv.get_json()['data'] == { + assert rv.status_code == 200, rv.json() + assert rv.json()['data'] == { "cpp_report": 'this is a report url 1', "python_report": 'this is a report url 2' } def test_detect_without_enough_request_args(self, client_teacher): rv = client_teacher.post('/copycat', json={}) - assert rv.status_code == 400, rv.get_json() - assert rv.get_json( - )['message'] == 'missing arguments! (In Json format)' + assert rv.status_code == 400, rv.json() + assert rv.json()['message'] == 'missing arguments! (In Json format)' def test_detect_with_student_does_not_exist(self, client_teacher, problem_ids): @@ -141,8 +140,8 @@ def test_detect_with_student_does_not_exist(self, client_teacher, 'ghost8787': 'studentDoesNotExist', }, }) - assert rv.status_code == 404, rv.get_json() - assert rv.get_json()['message'] == 'User: ghost8787 not found.' + assert rv.status_code == 404, rv.json() + assert rv.json()['message'] == 'User: ghost8787 not found.' def test_detect_with_empty_student_list(self, client_teacher, problem_ids): pid = problem_ids("teacher", 1, True)[0] @@ -152,8 +151,8 @@ def test_detect_with_empty_student_list(self, client_teacher, problem_ids): 'problemId': pid, 'studentNicknames': {}, }) - assert rv.status_code == 404, rv.get_json() - assert rv.get_json()['message'] == 'Empty student list.' + assert rv.status_code == 404, rv.json() + assert rv.json()['message'] == 'Empty student list.' def test_detect_without_perm(self, client_student, problem_ids): pid = problem_ids("teacher", 1, True)[0] @@ -165,8 +164,8 @@ def test_detect_without_perm(self, client_student, problem_ids): 'student': 'student', }, }) - assert rv.status_code == 403, rv.get_json() - assert rv.get_json()['message'] == 'Forbidden.' + assert rv.status_code == 403, rv.json() + assert rv.json()['message'] == 'Forbidden.' def test_detect_with_problem_does_not_exist(self, client_teacher): course = engine.Course.objects(teacher="teacher").first() @@ -178,8 +177,8 @@ def test_detect_with_problem_does_not_exist(self, client_teacher): 'student': 'student', }, }) - assert rv.status_code == 404, rv.get_json() - assert rv.get_json()['message'] == 'Problem not exist.' + assert rv.status_code == 404, rv.json() + assert rv.json()['message'] == 'Problem not exist.' def test_detect_with_course_does_not_exist(self, client_admin, problem_ids): @@ -192,8 +191,8 @@ def test_detect_with_course_does_not_exist(self, client_admin, 'student': 'student', }, }) - assert rv.status_code == 404, rv.get_json() - assert rv.get_json()['message'] == 'Course not found.' + assert rv.status_code == 404, rv.json() + assert rv.json()['message'] == 'Course not found.' def test_detect_without_config_TESTING(self, client_teacher, problem_ids, monkeypatch, app): @@ -209,7 +208,7 @@ def mock_get_report_task(user, problem_id, student_dict): ) monkeypatch.setattr(copycat, 'get_report_task', mock_get_report_task) - monkeypatch.setitem(app.config, 'TESTING', False) + monkeypatch.delenv('TESTING', raising=False) pid = problem_ids("teacher", 1, True)[0] course = engine.Course.objects(teacher="teacher").first() student_dict = { @@ -308,25 +307,25 @@ def mock_moss_send(self): assert problem.python_report_url == '' def test_get_report_by_url(self, monkeypatch): - from model.copycat import requests + from model.copycat import httpx - class mock_requests_get: + class mock_httpx_get: def __init__(self, text): self.text = text - monkeypatch.setattr(requests, 'get', mock_requests_get) + monkeypatch.setattr(httpx, 'get', mock_httpx_get) from model.copycat import get_report_by_url url = 'https://example.com:8787/abc?def=1234&A_A=Q_Q' assert get_report_by_url(url) == url def test_get_report_by_url_with_invalid_schema(self, monkeypatch): - from model.copycat import requests + from model.copycat import httpx - def mock_requests_get(_): - raise requests.exceptions.InvalidSchema + def mock_httpx_get(_): + raise httpx.InvalidURL('invalid') - monkeypatch.setattr(requests, 'get', mock_requests_get) + monkeypatch.setattr(httpx, 'get', mock_httpx_get) from model.copycat import get_report_by_url url = 'https://example.com:8787/abc?def=1234&A_A=Q_Q' assert get_report_by_url(url) == 'No report.' diff --git a/tests/test_course.py b/tests/test_course.py index f892b06a..62f08349 100644 --- a/tests/test_course.py +++ b/tests/test_course.py @@ -22,7 +22,7 @@ def test_add_with_invalid_username(self, client_admin): 'teacher': secrets.token_hex(4), }, ) - json = rv.get_json() + json = rv.json() assert json['message'] == 'User not found.' assert rv.status_code == 404 @@ -35,7 +35,7 @@ def test_add_with_invalid_course_name(self, client_admin): 'teacher': 'admin', }, ) - json = rv.get_json() + json = rv.json() assert json['message'] == 'Not allowed name.' assert rv.status_code == 400 @@ -48,7 +48,7 @@ def test_add(self, client_admin): 'teacher': 'admin', }, ) - json = rv.get_json() + json = rv.json() assert rv.status_code == 200 rv = client_admin.post( @@ -58,7 +58,7 @@ def test_add(self, client_admin): 'teacher': 'teacher', }, ) - json = rv.get_json() + json = rv.json() assert rv.status_code == 200 def test_add_with_existent_course_name(self, client_admin): @@ -70,7 +70,7 @@ def test_add_with_existent_course_name(self, client_admin): 'teacher': 'admin', }, ) - json = rv.get_json() + json = rv.json() assert json['message'] == 'Course exists.', json assert rv.status_code == 400 @@ -82,7 +82,7 @@ def test_edit_with_invalid_course_name(self, client_admin): 'newCourse': 'PE', 'teacher': 'teacher' }) - json = rv.get_json() + json = rv.json() assert json['message'] == 'Course not found.' assert rv.status_code == 404 @@ -94,7 +94,7 @@ def test_edit_with_invalid_username(self, client_admin): 'newCourse': 'PE', 'teacher': 'teacherr' }) - json = rv.get_json() + json = rv.json() assert json['message'] == 'User not found.' assert rv.status_code == 404 @@ -106,35 +106,39 @@ def test_edit(self, client_admin): 'newCourse': 'PE', 'teacher': 'teacher' }) - json = rv.get_json() + json = rv.json() assert rv.status_code == 200 def test_delete_with_invalid_course_name(self, client_admin): # delete a course with non-existent course name - rv = client_admin.delete('/course', json={'course': 'art'}) - json = rv.get_json() + rv = client_admin.request("DELETE", '/course', json={'course': 'art'}) + json = rv.json() assert json['message'] == 'Course not found.' assert rv.status_code == 404 def test_delete_with_non_owner(self, client_teacher): # delete a course with a user that is not the owner nor an admin - rv = client_teacher.delete('/course', json={'course': 'math'}) - json = rv.get_json() + rv = client_teacher.request("DELETE", + '/course', + json={'course': 'math'}) + json = rv.json() assert json['message'] == 'Forbidden.' assert rv.status_code == 403 def test_delete(self, client_admin): # delete a course - rv = client_admin.delete('/course', json={ - 'course': 'math', - }) - json = rv.get_json() + rv = client_admin.request("DELETE", + '/course', + json={ + 'course': 'math', + }) + json = rv.json() assert rv.status_code == 200 def test_view(self, client_admin): # Get all courses rv = client_admin.get('/course') - json = rv.get_json() + json = rv.json() assert rv.status_code == 200 # The first one is 'Public' assert len(json['data']) == 2 @@ -144,7 +148,7 @@ def test_view(self, client_admin): def test_view_with_non_member(self, client_student): # Get all courses with a user that is not a member rv = client_student.get('/course') - json = rv.get_json() + json = rv.json() assert rv.status_code == 200 assert json['data'] == [] @@ -170,7 +174,7 @@ def test_modify_invalid_course(self, client_admin): 'student': 'noobs' } }) - json = rv.get_json() + json = rv.json() assert json['message'] == 'Course not found.' assert rv.status_code == 404 @@ -184,7 +188,7 @@ def test_modify_when_not_in_course(self, forge_client): 'student': 'noobs' } }) - json = rv.get_json() + json = rv.json() assert json['message'] == 'You are not in this course.' assert rv.status_code == 403 @@ -197,7 +201,7 @@ def test_modify_with_invalid_user(self, client_admin): 'studentt': 'noobs' } }) - json = rv.get_json() + json = rv.json() assert 'User' in json['message'] assert rv.status_code == 404 @@ -221,11 +225,11 @@ def test_modify(self, client_teacher, problem_ids): 'student': 'noobs', } }) - json = rv.get_json() + json = rv.json() assert rv.status_code == 200 rv = client_teacher.get('/course') - json = rv.get_json() + json = rv.json() print(json) assert len(json['data']) == 1 @@ -237,8 +241,8 @@ def test_modify_with_ta_does_not_exist(self, client_teacher): 'TAs': ['TADoesNotExist'], 'studentNicknames': {} }) - assert rv.status_code == 404, rv.get_json() - assert rv.get_json()['message'] == 'User: TADoesNotExist not found.' + assert rv.status_code == 404, rv.json() + assert rv.json()['message'] == 'User: TADoesNotExist not found.' def test_modify_with_only_student(self, client_student): # modify a course when not TA up @@ -249,14 +253,14 @@ def test_modify_with_only_student(self, client_student): 'student': 'noobs' } }) - json = rv.get_json() + json = rv.json() assert json['message'] == 'Forbidden.' assert rv.status_code == 403 def test_view(self, client_student): # view a course rv = client_student.get('/course/math') - json = rv.get_json() + json = rv.json() assert rv.status_code == 200 assert json['data']['TAs'][0]['username'] == 'teacher' assert json['data']['teacher']['username'] == 'teacher' @@ -283,8 +287,8 @@ def test_grading_with_course_does_not_exist(self, client_admin): 'content': 'hard', 'score': 'A+', }) - assert rv.status_code == 404, rv.get_json() - assert rv.get_json()['message'] == 'Course not found.' + assert rv.status_code == 404, rv.json() + assert rv.json()['message'] == 'Course not found.' def test_grading_with_student_does_not_exist(self, client_admin): rv = client_admin.post('/course/Public/grade/StudentDoesNotExist', @@ -293,8 +297,8 @@ def test_grading_with_student_does_not_exist(self, client_admin): 'content': 'hard', 'score': 'A+', }) - assert rv.status_code == 404, rv.get_json() - assert rv.get_json()['message'] == 'The student is not in the course.' + assert rv.status_code == 404, rv.json() + assert rv.json()['message'] == 'The student is not in the course.' def test_add_score(self, client_admin): # add scores @@ -343,7 +347,7 @@ def test_add_existed_score(self, client_admin): }) assert rv.status_code == 400 - json = rv.get_json() + json = rv.json() assert json['message'] == 'This title is taken.' def test_modify_score(self, client_admin): @@ -368,8 +372,8 @@ def test_modify_existed_score(self, client_admin): 'score': 'E', }) - assert rv.status_code == 400, rv.get_json() - assert rv.get_json()['message'] == 'This title is taken.' + assert rv.status_code == 400, rv.json() + assert rv.json()['message'] == 'This title is taken.' def test_student_modify_score(self, client_student): # modify a score while being a student @@ -382,7 +386,7 @@ def test_student_modify_score(self, client_student): }) assert rv.status_code == 403 - json = rv.get_json() + json = rv.json() assert json['message'] == 'You can only view your score.' def test_modify_non_existed_score(self, client_admin): @@ -396,29 +400,31 @@ def test_modify_non_existed_score(self, client_admin): }) assert rv.status_code == 404 - json = rv.get_json() + json = rv.json() assert json['message'] == 'Score not found.' def test_delete_score(self, client_admin): # delete a score - rv = client_admin.delete('/course/math/grade/student', - json={'title': 'exam'}) + rv = client_admin.request("DELETE", + '/course/math/grade/student', + json={'title': 'exam'}) assert rv.status_code == 200 def test_delete_score_does_not_exist(self, client_admin): # delete a score - rv = client_admin.delete('/course/math/grade/student', - json={'title': 'exam'}) + rv = client_admin.request("DELETE", + '/course/math/grade/student', + json={'title': 'exam'}) - assert rv.status_code == 404, rv.get_json() - assert rv.get_json()['message'] == 'Score not found.' + assert rv.status_code == 404, rv.json() + assert rv.json()['message'] == 'Score not found.' def test_get_score(self, client_student): # get scores rv = client_student.get('/course/math/grade/student') - json = rv.get_json() + json = rv.json() assert rv.status_code == 200 assert len(json['data']) == 1 assert json['data'][0]['title'] == 'exam2 (edit)' @@ -430,7 +436,7 @@ def test_get_score_when_not_in_course(self, client_teacher): rv = client_teacher.get('/course/math/grade/student') assert rv.status_code == 403 - json = rv.get_json() + json = rv.json() assert json['message'] == 'You are not in this course.' @@ -438,27 +444,26 @@ class TestScoreBoard(BaseTester): def test_view_with_invalid_pids(self, client_admin): rv = client_admin.get(f'/course/Public/scoreboard?pids=invalid,pids') - assert rv.status_code == 400, rv.get_json() - assert rv.get_json( - )['message'] == 'Error occurred when parsing `pids`.' + assert rv.status_code == 400, rv.json() + assert rv.json()['message'] == 'Error occurred when parsing `pids`.' def test_view_with_invalid_start(self, client_admin): rv = client_admin.get( f'/course/Public/scoreboard?pids=1,2,3&start=invalid') - assert rv.status_code == 400, rv.get_json() - assert rv.get_json()['message'] == 'Type of `start` should be float.' + assert rv.status_code == 400, rv.json() + assert rv.json()['message'] == 'Type of `start` should be float.' def test_view_with_invalid_end(self, client_admin): rv = client_admin.get( f'/course/Public/scoreboard?pids=1,2,3&end=invalid') - assert rv.status_code == 400, rv.get_json() - assert rv.get_json()['message'] == 'Type of `end` should be float.' + assert rv.status_code == 400, rv.json() + assert rv.json()['message'] == 'Type of `end` should be float.' def test_admin_can_view_scoreboard(self, forge_client: ForgeClient): course = utils.course.create_course() client = forge_client('first_admin') rv = client.get(f'/course/{course.course_name}/scoreboard?pids=1,2,3') - assert rv.status_code == 200, rv.json + assert rv.status_code == 200, rv.json() def test_teacher_can_view_scoreboard(self, forge_client: ForgeClient): course = utils.course.create_course() @@ -466,7 +471,7 @@ def test_teacher_can_view_scoreboard(self, forge_client: ForgeClient): rv = client.get( f'/course/{course.course_name}/scoreboard?pids=1,2,3&start=1&end=1' ) - assert rv.status_code == 200, rv.json + assert rv.status_code == 200, rv.json() def test_student_cannot_view_scoreboard( self, @@ -476,7 +481,7 @@ def test_student_cannot_view_scoreboard( course = utils.course.create_course(students=[user]) client = forge_client(user.username) rv = client.get(f'/course/{course.course_name}/scoreboard?pids=1,2,3') - assert rv.status_code == 403, rv.json + assert rv.status_code == 403, rv.json() def test_teacher_role_cannot_view_scoreboard( self, @@ -491,7 +496,7 @@ def test_teacher_role_cannot_view_scoreboard( assert user != course.teacher client = forge_client(user.username) rv = client.get(f'/course/{course.course_name}/scoreboard?pids=1,2,3') - assert rv.status_code == 403, rv.json + assert rv.status_code == 403, rv.json() class TestMongoCourse(BaseTester): @@ -564,25 +569,24 @@ def test_course_summary(self, client_admin, app): scoreboard_status=0, ) - with app.app_context(): - utils.submission.create_submission( - user=User('student'), - problem=math_problem, - score=100, - ) - utils.submission.create_submission( - user=User('student'), - problem=history_problem, - score=100, - ) - utils.submission.create_submission( - user=User('teacher'), - problem=history_problem, - score=0, - ) + utils.submission.create_submission( + user=User('student'), + problem=math_problem, + score=100, + ) + utils.submission.create_submission( + user=User('student'), + problem=history_problem, + score=100, + ) + utils.submission.create_submission( + user=User('teacher'), + problem=history_problem, + score=0, + ) rv = client_admin.get('/course/summary') - json = rv.get_json() + json = rv.json() assert rv.status_code == 200, json assert json['data']['courseCount'] == 3 # Includes 'Public' course diff --git a/tests/test_get_homewrok_problem.py b/tests/test_get_homewrok_problem.py index 9abedb48..d6fc3ffb 100644 --- a/tests/test_get_homewrok_problem.py +++ b/tests/test_get_homewrok_problem.py @@ -5,7 +5,7 @@ import pytest from mongo import * from unittest.mock import patch -from flask.testing import FlaskClient +from starlette.testclient import TestClient from tests.base_tester import BaseTester, random_string from tests.conftest import ForgeClient @@ -27,7 +27,7 @@ def homework_name(self): @pytest.fixture def course_data( - client_admin: FlaskClient, + client_admin: TestClient, problem_ids, ): BaseTester.setup_class() @@ -45,11 +45,7 @@ def course_data( Course.add_course(cd.name, cd.teacher) # add students and TA - client_admin.set_cookie( - "piann", - User("admin").secret, - domain='test.test', - ) + client_admin.cookies.set("piann", User("admin").secret) rv = client_admin.put( f"/course/{cd.name}", json={ @@ -57,8 +53,8 @@ def course_data( "studentNicknames": cd.students }, ) - client_admin.delete_cookie("piann", domain='test.test') - assert rv.status_code == 200, rv.get_json() + del client_admin.cookies["piann"] + assert rv.status_code == 200, rv.json() # add homework public_problem_ids = problem_ids(cd.teacher, 1, add_to_course=True) diff --git a/tests/test_health.py b/tests/test_health.py index 39618330..cfa055e5 100644 --- a/tests/test_health.py +++ b/tests/test_health.py @@ -25,7 +25,7 @@ def test_health(client, monkeypatch): monkeypatch.setattr(MongoClient, '__init__', lambda *_: None) monkeypatch.setattr(MongoClient, 'server_info', mock_mongo_success) rv = client.get('/health') - rv_json = rv.get_json() + rv_json = rv.json() assert rv.status_code == 200, rv_json diff --git a/tests/test_homework.py b/tests/test_homework.py index 1a57a948..ab6f1136 100644 --- a/tests/test_homework.py +++ b/tests/test_homework.py @@ -2,7 +2,7 @@ from dataclasses import dataclass, field from typing import Dict, List import pytest -from flask.testing import FlaskClient +from starlette.testclient import TestClient from tests.base_tester import BaseTester, random_string from tests.conftest import ForgeClient from mongo import * @@ -25,7 +25,7 @@ def homework_name(self): @pytest.fixture def course_data( - client_admin: FlaskClient, + client_admin: TestClient, problem_ids, ): BaseTester.setup_class() @@ -41,11 +41,7 @@ def course_data( # add course Course.add_course(cd.name, cd.teacher) # add students and TA - client_admin.set_cookie( - 'piann', - User('admin').secret, - domain='test.test', - ) + client_admin.cookies.set('piann', User('admin').secret) rv = client_admin.put( f'/course/{cd.name}', json={ @@ -53,8 +49,8 @@ def course_data( 'studentNicknames': cd.students }, ) - client_admin.delete_cookie('piann', domain='test.test') - assert rv.status_code == 200, rv.get_json() + del client_admin.cookies['piann'] + assert rv.status_code == 200, rv.json() # add homework hw = Homework.add( user=User(cd.teacher).obj, @@ -115,7 +111,7 @@ def test_valid_filter( }, ]}, ) - assert rv.status_code == 200, rv.data + assert rv.status_code == 200, rv.content hw.reload() assert hw.ip_filters == [_filter] @@ -146,7 +142,7 @@ def test_invalid_filter( }, ]}, ) - assert rv.status_code == 400, rv.data + assert rv.status_code == 400, rv.content hw.reload() assert hw.ip_filters == [] @@ -157,7 +153,7 @@ def test_get_single_homework(self, forge_client, course_data): # get teacher client client = forge_client(course_data.teacher) rv = client.get(f'/homework/{course_data.homework_ids[0]}') - rv_json = rv.get_json() + rv_json = rv.json() rv_data = rv_json['data'] assert rv.status_code == 200, rv_json for key in ('name', 'start', 'end', 'problemIds'): @@ -167,7 +163,7 @@ def test_get_list_of_homewrok(self, forge_client, course_data): c_data = course_data client = forge_client(c_data.teacher) rv = client.get(f'/course/{c_data.name}/homework') - rv_json = rv.get_json() + rv_json = rv.json() rv_data = rv_json['data'] assert rv.status_code == 200, rv_json assert len(rv_data) == len(c_data.homework_ids), rv_data @@ -177,7 +173,7 @@ def test_get_list_of_homework_from_not_exist_course( course_name = 'not_exist_course' client = forge_client('admin') rv = client.get(f'/course/{course_name}/homework') - rv_json = rv.get_json() + rv_json = rv.json() assert rv.status_code == 404, rv_json assert rv_json['message'] == 'course not exists' @@ -806,9 +802,8 @@ def test_do_penalty(self, app): student = User(student_name) hw.add_student(students=[student]) - with app.app_context(): - submission = utils.submission.create_submission(user=student, - problem=problem) + submission = utils.submission.create_submission(user=student, + problem=problem) stat = hw.student_status[student_name][str(problem.id)] if 'rawScore' not in stat: diff --git a/tests/test_mongo_utils.py b/tests/test_mongo_utils.py index d058feed..f49a7d18 100644 --- a/tests/test_mongo_utils.py +++ b/tests/test_mongo_utils.py @@ -62,8 +62,7 @@ def test_doc_required_replace_des(caplog, app): def add(course): pass - with app.app_context(): - add(course_name=None, course="Will Be Replaced") + add(course_name=None, course="Will Be Replaced") assert "WARNING" in caplog.text assert "replace a existed argument" in caplog.text diff --git a/tests/test_post.py b/tests/test_post.py index 426e923a..5a42fb89 100644 --- a/tests/test_post.py +++ b/tests/test_post.py @@ -38,7 +38,7 @@ def test_add_post_to_invalid_course(self, client_admin): 'title': 'Work', 'content': 'Coding.' }) - json = rv.get_json() + json = rv.json() assert rv.status_code == 404 assert json['message'] == 'Course not exist.' @@ -50,15 +50,15 @@ def test_add_post(self, client_student): 'title': 'Work', 'content': 'Coding.' }) - json = rv.get_json() + json = rv.json() assert rv.status_code == 200 def test_add_post_to_public(self, client_admin): rv = client_admin.post('/post', json={ 'course': 'Public', }) - assert rv.status_code == 403, rv.get_json() - assert rv.get_json()['message'] == 'You can not add post in system.' + assert rv.status_code == 403, rv.json() + assert rv.json()['message'] == 'You can not add post in system.' def test_add_post_with_course_and_thread_id(self, client_admin): rv = client_admin.post('/post', @@ -66,14 +66,14 @@ def test_add_post_with_course_and_thread_id(self, client_admin): 'course': 'CourseName', 'targetThreadId': 'ThreadId', }) - assert rv.status_code == 400, rv.get_json() - assert rv.get_json( + assert rv.status_code == 400, rv.json() + assert rv.json( )['message'] == 'Request is fail,course or target_thread_id must be none.' def test_add_post_without_course_and_thread_id(self, client_admin): rv = client_admin.post('/post', json={}) - assert rv.status_code == 400, rv.get_json() - assert rv.get_json( + assert rv.status_code == 400, rv.json() + assert rv.json( )['message'] == 'Request is fail,course and target_thread_id are both none.' def test_add_post_when_not_in_course(self, client_student): @@ -86,13 +86,13 @@ def test_add_post_when_not_in_course(self, client_student): 'title': 'Work', 'content': 'Coding.' }) - json = rv.get_json() + json = rv.json() assert rv.status_code == 403 assert json['message'] == 'You are not in this course.' def test_add_reply(self, client_student): rvget = client_student.get('/post/math') - jsonget = rvget.get_json() + jsonget = rvget.json() id = jsonget['data'][0]['thread']['id'] # get post id (thread) rv = client_student.post('/post', json={ @@ -100,12 +100,12 @@ def test_add_reply(self, client_student): 'title': 'reply', 'content': 'reply message.' }) - json = rv.get_json() + json = rv.json() assert rv.status_code == 200 def test_add_reply_too_deep(self, client_student): rvget = client_student.get('/post/math') - id = rvget.get_json()['data'][0]['thread']['reply'][0]['id'] + id = rvget.json()['data'][0]['thread']['reply'][0]['id'] rv = client_student.post('/post', json={ 'targetThreadId': id, @@ -113,16 +113,15 @@ def test_add_reply_too_deep(self, client_student): 'content': 'reply message.' }) rvget = client_student.get('/post/math') - id = rvget.get_json( - )['data'][0]['thread']['reply'][0]['reply'][0]['id'] + id = rvget.json()['data'][0]['thread']['reply'][0]['reply'][0]['id'] rv = client_student.post('/post', json={ 'targetThreadId': id, 'title': 'Deeeeeeper reply', 'content': 'reply message.' }) - assert rv.status_code == 403, rv.get_json() - assert rv.get_json( + assert rv.status_code == 403, rv.json() + assert rv.json( )['message'] == 'Forbidden,you can not reply too deap (not open).' def test_add_reply_to_not_exist_post(self, client_student): @@ -133,7 +132,7 @@ def test_add_reply_to_not_exist_post(self, client_student): 'title': 'reply', 'content': 'reply message.' }) - json = rv.get_json() + json = rv.json() assert rv.status_code == 404 assert json['message'] == 'Post/reply not exist.' @@ -143,39 +142,44 @@ def test_edit_post_without_perm(self, forge_client): rv = client_student.put('/post', json={ 'targetThreadId': id, }) - assert rv.status_code == 403, rv.get_json() - assert rv.get_json( + assert rv.status_code == 403, rv.json() + assert rv.json( )['message'] == 'Forbidden, you don\'t have enough permission to edit it.' def test_delete_post_without_perm(self, forge_client): id = str(Course('math').posts[0].id) client_student = forge_client('student-2') - rv = client_student.delete('/post', json={'targetThreadId': id}) - assert rv.status_code == 403, rv.get_json() - assert rv.get_json( + rv = client_student.request("DELETE", + '/post', + json={'targetThreadId': id}) + assert rv.status_code == 403, rv.json() + assert rv.json( )['message'] == 'Forbidden, you don\'t have enough permission to delete it.' def test_delete_post(self, client_student): rvget = client_student.get('/post/math') - jsonget = rvget.get_json() + jsonget = rvget.json() id = jsonget['data'][0]['thread']['id'] # get post id (thread) - rv = client_student.delete('/post', json={'targetThreadId': id}) - json = rv.get_json() + rv = client_student.request("DELETE", + '/post', + json={'targetThreadId': id}) + json = rv.json() assert rv.status_code == 200 def test_delete_post_without_thread_id(self, client_student): - rv = client_student.delete('/post', - json={ - 'course': 'math', - 'content': 'The reply is edited.' - }) - assert rv.status_code == 400, rv.get_json() - assert rv.get_json( + rv = client_student.request("DELETE", + '/post', + json={ + 'course': 'math', + 'content': 'The reply is edited.' + }) + assert rv.status_code == 400, rv.json() + assert rv.json( )['message'] == 'Request is fail,you should provide target_thread_id replace course.' def test_add_reply_to_deleted_post(self, client_student): rvget = client_student.get('/post/math') - jsonget = rvget.get_json() + jsonget = rvget.json() id = jsonget['data'][0]['thread']['id'] # get post id (thread) rv = client_student.post('/post', json={ @@ -183,20 +187,20 @@ def test_add_reply_to_deleted_post(self, client_student): 'title': 'reply to delete', 'content': 'reply to delete message.' }) - json = rv.get_json() + json = rv.json() assert rv.status_code == 403 assert json['message'] == 'Forbidden,the post/reply is deleted.' def test_edit_reply(self, client_student): rvget = client_student.get('/post/math') - jsonget = rvget.get_json() + jsonget = rvget.json() id = jsonget['data'][0]['thread']['reply'][0]['id'] # get reply id rv = client_student.put('/post', json={ 'targetThreadId': id, 'content': 'The reply is edited.' }) - json = rv.get_json() + json = rv.json() assert rv.status_code == 200 def test_edit_reply_without_thread_id(self, client_student): @@ -205,13 +209,13 @@ def test_edit_reply_without_thread_id(self, client_student): 'course': 'math', 'content': 'The reply is edited.' }) - assert rv.status_code == 400, rv.get_json() - assert rv.get_json( + assert rv.status_code == 400, rv.json() + assert rv.json( )['message'] == 'Request is fail,you should provide target_thread_id replace course.' def test_view(self, client_student): rv = client_student.get('/post/math') - json = rv.get_json() + json = rv.json() assert rv.status_code == 200 assert json['data'][0]['title'] == '*The Post was deleted*' assert json['data'][0]['thread']['content'] == '*Content was deleted.*' @@ -223,37 +227,37 @@ def test_view(self, client_student): def test_view_with_course_does_not_exist(self, client_student): rv = client_student.get('/post/CourseDoesNotExist') - assert rv.status_code == 404, rv.get_json() - assert rv.get_json()['message'] == 'Course not found.' + assert rv.status_code == 404, rv.json() + assert rv.json()['message'] == 'Course not found.' def test_view_from_non_course_member(self, make_course, forge_client): c_data = make_course('teacher') client_student = forge_client('student') rv = client_student.get(f'/post/{c_data.name}') - assert rv.status_code == 403, rv.get_json() - assert rv.get_json()['message'] == 'You are not in this course.' + assert rv.status_code == 403, rv.json() + assert rv.json()['message'] == 'You are not in this course.' def test_view_thread_does_not_exist(self, client_student): rv = client_student.get('/post/view/math/ThreadDoesNotExist') - assert rv.status_code == 200, rv.get_json() - assert rv.get_json()['data'] == [] + assert rv.status_code == 200, rv.json() + assert rv.json()['data'] == [] def test_view_thread_with_course_does_not_exist(self, client_student): rv = client_student.get('/post/view/CourseDoesNotExist/aaabbbccc') - assert rv.status_code == 404, rv.get_json() - assert rv.get_json()['message'] == 'Course not found.' + assert rv.status_code == 404, rv.json() + assert rv.json()['message'] == 'Course not found.' def test_view_thread_from_non_course_member(self, make_course, forge_client): c_data = make_course('teacher') client_student = forge_client('student') rv = client_student.get(f'/post/view/{c_data.name}/aaabbbccc') - assert rv.status_code == 403, rv.get_json() - assert rv.get_json()['message'] == 'You are not in this course.' + assert rv.status_code == 403, rv.json() + assert rv.json()['message'] == 'You are not in this course.' def test_view_thread_without_thread_id(self, make_course, forge_client): c_data = make_course('teacher') client_teacher = forge_client('teacher') - rv = client_teacher.get(f'/post/view/{c_data.name}/aaabbcc/') - assert rv.status_code == 200, rv.get_json() - assert rv.get_json()['data'] == [] + rv = client_teacher.get(f'/post/view/{c_data.name}/aaabbcc') + assert rv.status_code == 200, rv.json() + assert rv.json()['data'] == [] diff --git a/tests/test_problem.py b/tests/test_problem.py index 984d1507..d8264fb8 100644 --- a/tests/test_problem.py +++ b/tests/test_problem.py @@ -8,7 +8,7 @@ def get_file(file): with open("./tests/problem_test_case/" + file, 'rb') as f: - return {'case': (io.BytesIO(f.read()), "test_case.zip")} + return {'case': ("test_case.zip", io.BytesIO(f.read()))} def description_dict(): @@ -62,7 +62,7 @@ def test_add_with_invalid_value(self, client_admin): '/problem/manage', json=request_json_with_invalid_json, ) - json = rv.get_json() + json = rv.json() assert rv.status_code == 400 assert json['status'] == 'err' assert json['message'] == 'Invalid or missing arguments.' @@ -91,7 +91,7 @@ def test_add_with_missing_argument(self, client_admin): } rv = client_admin.post('/problem/manage', json=request_json_with_missing_argument) - json = rv.get_json() + json = rv.json() assert json['message'] == 'Invalid or missing arguments.' assert rv.status_code == 400 assert json['status'] == 'err' @@ -119,14 +119,14 @@ def test_add_offline_problem(self, client_admin): } } rv = client_admin.post('/problem/manage', json=request_json) - json = rv.get_json() + json = rv.json() id = json['data']['problemId'] rv = client_admin.put( f'/problem/manage/{id}', - data=get_file('default/test_case.zip'), + files=get_file('default/test_case.zip'), ) - json = rv.get_json() + json = rv.json() assert rv.status_code == 200, json assert json['status'] == 'ok' assert json['message'] == 'Success.' @@ -154,14 +154,14 @@ def test_add_online_problem(self, client_admin): } } rv = client_admin.post('/problem/manage', json=request_json) - json = rv.get_json() + json = rv.json() id = json['data']['problemId'] rv = client_admin.put( f'/problem/manage/{id}', - data=get_file('default/test_case.zip'), + files=get_file('default/test_case.zip'), ) - json = rv.get_json() + json = rv.json() assert rv.status_code == 200 assert json['status'] == 'ok' assert json['message'] == 'Success.' @@ -171,31 +171,31 @@ def test_add_problem_with_empty_course_list(self, client_admin): 'courses': [], } rv = client_admin.post('/problem/manage', json=request_json) - assert rv.status_code == 400, rv.get_json() - assert rv.get_json()['message'] == 'No course provided' + assert rv.status_code == 400, rv.json() + assert rv.json()['message'] == 'No course provided' def test_add_problem_with_course_does_not_exist(self, client_admin): request_json = { 'courses': ['CourseDoesNotExist'], } rv = client_admin.post('/problem/manage', json=request_json) - assert rv.status_code == 404, rv.get_json() - assert rv.get_json()['message'] == 'Course not found' + assert rv.status_code == 404, rv.json() + assert rv.json()['message'] == 'Course not found' def test_get_problem_list_with_nan_offest(self, client_admin): rv = client_admin.get('/problem?offset=BadOffset') - assert rv.status_code == 400, rv.get_json() - assert rv.get_json()['message'] == 'offset and count must be integer!' + assert rv.status_code == 400, rv.json() + assert rv.json()['message'] == 'offset and count must be integer!' def test_get_problem_list_with_negtive_offest(self, client_admin): rv = client_admin.get('/problem?offset=-1') - assert rv.status_code == 400, rv.get_json() - assert rv.get_json()['message'] == 'invalid offset' + assert rv.status_code == 400, rv.json() + assert rv.json()['message'] == 'invalid offset' # admin get problem list (GET /problem) def test_admin_get_problem_list(self, client_admin): rv = client_admin.get('/problem?offset=0&count=5') - json = rv.get_json() + json = rv.json() assert rv.status_code == 200 assert json['status'] == 'ok' assert json['message'] == 'Success.' @@ -224,7 +224,7 @@ def test_admin_get_problem_list(self, client_admin): # admin get problem list with a filter (GET /problem) def test_admin_get_problem_list_with_filter(self, client_admin): rv = client_admin.get('/problem?offset=0&count=5&course=English') - json = rv.get_json() + json = rv.json() assert rv.status_code == 200 assert json['status'] == 'ok' assert json['message'] == 'Success.' @@ -261,7 +261,7 @@ def test_admin_get_problem_list_with_unexist_params(self, client_admin): # student get problem list (GET /problem) def test_student_get_problem_list(self, client_student): rv = client_student.get('/problem?offset=0&count=5') - json = rv.get_json() + json = rv.json() assert rv.status_code == 200 assert json['status'] == 'ok' assert json['message'] == 'Success.' @@ -281,8 +281,8 @@ def test_view_problem_from_invalid_ip(self, client_student, monkeypatch): from model.problem import Problem monkeypatch.setattr(Problem, 'is_valid_ip', lambda *_: False) rv = client_student.get('/problem/4') - assert rv.status_code == 403, rv.get_json() - assert rv.get_json()['message'] == 'Invalid IP address.' + assert rv.status_code == 403, rv.json() + assert rv.json()['message'] == 'Invalid IP address.' def test_view_template_problem(self, client_admin): request_json = { @@ -308,14 +308,14 @@ def test_view_template_problem(self, client_admin): rv = client_admin.post('/problem/manage', json=request_json) assert rv.status_code == 200 rv = client_admin.get('/problem/5') - assert rv.status_code == 200, rv.get_json() - assert rv.get_json( + assert rv.status_code == 200, rv.json() + assert rv.json( )['data']['fillInTemplate'] == 'This is a fill in template.' # admin view offline problem (GET /problem/) def test_admin_view_offline_problem(self, client_admin): rv = client_admin.get('/problem/3') - json = rv.get_json() + json = rv.json() assert rv.status_code == 200 assert json['status'] == 'ok' assert json['message'] == 'Problem can view.' @@ -355,14 +355,14 @@ def test_admin_view_offline_problem(self, client_admin): # student view offline problem (GET /problem/) def test_student_view_offline_problem(self, client_student): rv = client_student.get('/problem/3') - json = rv.get_json() + json = rv.json() assert rv.status_code == 403 assert json['status'] == 'err' # student view online problem (GET /problem/) def test_student_view_online_problem(self, client_student): rv = client_student.get('/problem/4') - json = rv.get_json() + json = rv.json() assert rv.status_code == 200 assert json['status'] == 'ok' assert json['message'] == 'Problem can view.' @@ -402,7 +402,7 @@ def test_student_view_online_problem(self, client_student): # student view problem not exist (GET /problem/) def test_student_view_problem_not_exist(self, client_student): rv = client_student.get('/problem/0') - json = rv.get_json() + json = rv.json() assert rv.status_code == 404 assert json['status'] == 'err' @@ -429,7 +429,7 @@ def test_student_edit_problem(self, client_student): }, } rv = client_student.put('/problem/manage/1', json=request_json) - json = rv.get_json() + json = rv.json() assert rv.status_code == 403 assert json['status'] == 'err' assert json['message'] == 'Insufficient Permissions' @@ -461,7 +461,7 @@ def test_teacher_not_owner_edit_problem(self, client_teacher): f'/problem/manage/{prob.id}', json=request_json, ) - json = rv.get_json() + json = rv.json() assert rv.status_code == 403 assert json['status'] == 'err' @@ -488,7 +488,7 @@ def test_admin_edit_problem_with_non_exist_course(self, client_admin): } } rv = client_admin.put('/problem/manage/1', json=request_json) - json = rv.get_json() + json = rv.json() print(json) assert rv.status_code == 404 @@ -514,8 +514,8 @@ def test_edit_problem_with_course_does_not_exist(self, client_admin): } } rv = client_admin.put('/problem/manage/3', json=request_json) - assert rv.status_code == 404, rv.get_json() - assert rv.get_json()['message'] == 'Course not found.' + assert rv.status_code == 404, rv.json() + assert rv.json()['message'] == 'Course not found.' def test_edit_problem_with_name_is_too_long(self, client_admin): oo = 'o' * 64 @@ -540,8 +540,8 @@ def test_edit_problem_with_name_is_too_long(self, client_admin): } } rv = client_admin.put('/problem/manage/3', json=request_json) - assert rv.status_code == 400, rv.get_json() - assert rv.get_json()['message'] == 'Invalid or missing arguments.' + assert rv.status_code == 400, rv.json() + assert rv.json()['message'] == 'Invalid or missing arguments.' # admin change the name of a problem (PUT /problem/manage/) def test_admin_edit_problem(self, client_admin): @@ -566,7 +566,7 @@ def test_admin_edit_problem(self, client_admin): } } rv = client_admin.put('/problem/manage/3', json=request_json) - json = rv.get_json() + json = rv.json() print(json) assert rv.status_code == 200 assert json['status'] == 'ok' @@ -575,7 +575,7 @@ def test_admin_edit_problem(self, client_admin): def test_admin_manage_problem(self, client_admin): pid = 3 rv = client_admin.get(f'/problem/manage/{pid}') - json = rv.get_json() + json = rv.json() assert rv.status_code == 200 assert json['status'] == 'ok' assert json['data'] == { @@ -607,9 +607,9 @@ def test_admin_manage_problem(self, client_admin): def test_update_problem_test_case_with_non_zip_file(self, client_admin): rv = client_admin.put('/problem/manage/3', - data=get_file('bogay/0000.in')) - assert rv.status_code == 400, rv.get_json() - assert rv.get_json()['message'] == 'File is not a zip file' + files=get_file('bogay/0000.in')) + assert rv.status_code == 400, rv.json() + assert rv.json()['message'] == 'File is not a zip file' def test_update_problem_test_case_with_ambiguous_test_case( self, client_admin, monkeypatch): @@ -617,9 +617,9 @@ def test_update_problem_test_case_with_ambiguous_test_case( monkeypatch.setattr(SimpleIO, 'validate', lambda *_: None) monkeypatch.setattr(ContextIO, 'validate', lambda *_: None) rv = client_admin.put('/problem/manage/3', - data=get_file('bogay/test_case.zip')) - assert rv.status_code == 400, rv.get_json() - assert rv.get_json()['message'] == 'ambiguous test case format' + files=get_file('bogay/test_case.zip')) + assert rv.status_code == 400, rv.json() + assert rv.json()['message'] == 'ambiguous test case format' def test_update_problem_test_case_raise_does_not_exist_error( self, client_admin, monkeypatch): @@ -630,35 +630,35 @@ def mock_update_test_case(*_): from mongo.problem import Problem monkeypatch.setattr(Problem, 'update_test_case', mock_update_test_case) rv = client_admin.put('/problem/manage/3', - data=get_file('bogay/test_case.zip')) - assert rv.status_code == 404, rv.get_json() - assert rv.get_json()['message'] == 'Error from mock update_test_case.' + files=get_file('bogay/test_case.zip')) + assert rv.status_code == 404, rv.json() + assert rv.json()['message'] == 'Error from mock update_test_case.' def test_update_problem_test_case_with_unknown_content_type( self, client_admin): rv = client_admin.put('/problem/manage/3', headers={'Content-type': 'unknown/content-type'}) - assert rv.status_code == 400, rv.get_json() - assert rv.get_json()['message'] == 'Unknown content type' - assert rv.get_json()['data']['contentType'] == 'unknown/content-type' + assert rv.status_code == 400, rv.json() + assert rv.json()['message'] == 'Unknown content type' + assert rv.json()['data']['contentType'] == 'unknown/content-type' def test_student_cannot_get_test_case(self, client_student): rv = client_student.get('/problem/3/testcase') - assert rv.status_code == 403, rv.get_json() - assert rv.get_json()['message'] == 'Not enough permission' + assert rv.status_code == 403, rv.json() + assert rv.json()['message'] == 'Not enough permission' def test_admin_update_problem_test_case(self, client_admin, monkeypatch): # FIXME: it should be impl in mock monkeypatch.setattr( Problem, 'get_test_case', - lambda *_: get_file('bogay/test_case.zip')['case'][0]) + lambda *_: get_file('bogay/test_case.zip')['case'][1]) # update test case rv, rv_json, rv_data = BaseTester.request( client_admin, 'put', '/problem/manage/3', - data=get_file('bogay/test_case.zip'), + files=get_file('bogay/test_case.zip'), ) assert rv.status_code == 200, rv_json assert Problem(3).test_case.case_zip_minio_path is not None @@ -669,7 +669,7 @@ def test_admin_update_problem_test_case(self, client_admin, monkeypatch): '/problem/3/testcase', ) assert rv.status_code == 200 - with ZipFile(io.BytesIO(rv.data)) as zf: + with ZipFile(io.BytesIO(rv.content)) as zf: ns = sorted(zf.namelist()) in_ns = ns[::2] out_ns = ns[1::2] @@ -682,19 +682,19 @@ def test_admin_update_problem_test_case(self, client_admin, monkeypatch): def test_get_testdata_with_invalid_token(self, client): rv = client.get('/problem/3/testdata?token=InvalidToken8787') - assert rv.status_code == 401, rv.get_json() - assert rv.get_json()['message'] == 'Invalid sandbox token' + assert rv.status_code == 401, rv.json() + assert rv.json()['message'] == 'Invalid sandbox token' def test_get_testdata(self, client, monkeypatch): # FIXME: it should be impl in mock monkeypatch.setattr( Problem, 'get_test_case', - lambda *_: get_file('bogay/test_case.zip')['case'][0]) + lambda *_: get_file('bogay/test_case.zip')['case'][1]) from model.problem import sandbox monkeypatch.setattr(sandbox, 'find_by_token', lambda *_: True) rv = client.get('/problem/3/testdata?token=ValidToken') assert rv.status_code == 200 - with ZipFile(io.BytesIO(rv.data)) as zf: + with ZipFile(io.BytesIO(rv.content)) as zf: ns = sorted(zf.namelist()) in_ns = ns[::2] out_ns = ns[1::2] @@ -707,39 +707,39 @@ def test_get_testdata(self, client, monkeypatch): def test_get_checksum_with_invalid_token(self, client): rv = client.get('/problem/3/checksum?token=InvalidToken8787') - assert rv.status_code == 401, rv.get_json() - assert rv.get_json()['message'] == 'Invalid sandbox token' + assert rv.status_code == 401, rv.json() + assert rv.json()['message'] == 'Invalid sandbox token' def test_get_checksum_with_problem_does_not_exist(self, client, monkeypatch): from model.problem import sandbox monkeypatch.setattr(sandbox, 'find_by_token', lambda *_: True) rv = client.get('/problem/878787/checksum?token=SandboxToken') - assert rv.status_code == 404, rv.get_json() - assert rv.get_json()['message'] == 'problem [878787] not found' + assert rv.status_code == 404, rv.json() + assert rv.json()['message'] == 'problem [878787] not found' def test_get_checksum(self, client, monkeypatch): # FIXME: it should be impl in mock monkeypatch.setattr( Problem, 'get_test_case', - lambda *_: get_file('bogay/test_case.zip')['case'][0]) + lambda *_: get_file('bogay/test_case.zip')['case'][1]) from model.problem import sandbox monkeypatch.setattr(sandbox, 'find_by_token', lambda *_: True) rv = client.get('/problem/3/checksum?token=SandboxToken') - assert rv.status_code == 200, rv.get_json() - assert rv.get_json()['data'] == 'b80aa4fad6b5dea9a5bca3237ac3ba89' + assert rv.status_code == 200, rv.json() + assert rv.json()['data'] == 'b80aa4fad6b5dea9a5bca3237ac3ba89' def test_get_meta_with_invalid_token(self, client): rv = client.get('/problem/3/meta?token=InvalidToken8787') - assert rv.status_code == 401, rv.get_json() - assert rv.get_json()['message'] == 'Invalid sandbox token' + assert rv.status_code == 401, rv.json() + assert rv.json()['message'] == 'Invalid sandbox token' def test_get_meta_with_problem_does_not_exist(self, client, monkeypatch): from model.problem import sandbox monkeypatch.setattr(sandbox, 'find_by_token', lambda *_: True) rv = client.get('/problem/878787/meta?token=SandboxToken') - assert rv.status_code == 404, rv.get_json() - assert rv.get_json()['message'] == 'problem [878787] not found' + assert rv.status_code == 404, rv.json() + assert rv.json()['message'] == 'problem [878787] not found' def test_get_meta(self, client, monkeypatch): @@ -752,8 +752,8 @@ class MockConfig: from mongo.sandbox import Submission monkeypatch.setattr(Submission, 'config', MockConfig) rv = client.get('/problem/3/meta?token=SandboxToken') - assert rv.status_code == 200, rv.get_json() - assert rv.get_json()['data'] == { + assert rv.status_code == 200, rv.json() + assert rv.json()['data'] == { 'tasks': [{ 'caseCount': 1, 'memoryLimit': 1000, @@ -772,7 +772,7 @@ def test_admin_update_problem_test_case_with_invalid_data( client_admin, 'put', f'/problem/manage/{prob.id}', - data=get_file('task-exceed/test_case.zip'), + files=get_file('task-exceed/test_case.zip'), ) assert rv.status_code == 400 @@ -780,7 +780,7 @@ def test_admin_update_problem_test_case_with_invalid_data( def test_teacher_not_owner_manage_problem(self, client_teacher): prob = utils.problem.create_problem() rv = client_teacher.get(f'/problem/manage/{prob.id}') - json = rv.get_json() + json = rv.json() assert rv.status_code == 403 assert json['status'] == 'err' @@ -788,14 +788,14 @@ def test_teacher_not_owner_manage_problem(self, client_teacher): def test_student_manage_problem(self, client_student): prob = utils.problem.create_problem() rv = client_student.get(f'/problem/manage/{prob.id}') - json = rv.get_json() + json = rv.json() assert rv.status_code == 403 assert json['status'] == 'err' # student delete problem (DELETE /problem/manage/) def test_student_delete_problem(self, client_student): rv = client_student.delete('/problem/manage/1') - json = rv.get_json() + json = rv.json() assert rv.status_code == 403 assert json['status'] == 'err' assert json['message'] == 'Insufficient Permissions' @@ -804,7 +804,7 @@ def test_student_delete_problem(self, client_student): def test_teacher_not_owner_delete_problem(self, client_teacher): prob = utils.problem.create_problem() rv = client_teacher.delete(f'/problem/manage/{prob.id}') - json = rv.get_json() + json = rv.json() assert rv.status_code == 403 assert json['status'] == 'err' @@ -812,7 +812,7 @@ def test_teacher_not_owner_delete_problem(self, client_teacher): def test_admin_delete_problem(self, client_admin): prob = utils.problem.create_problem() rv = client_admin.delete(f'/problem/manage/{prob.id}') - json = rv.get_json() + json = rv.json() assert rv.status_code == 200 assert json['status'] == 'ok' assert not Problem(prob.id) @@ -839,8 +839,8 @@ def test_teacher_cannot_copy_problem_from_other_course( 'problemId': 3, 'target': c_data.name }) - assert rv.status_code == 403, rv.get_json() - assert rv.get_json()['message'] == 'Problem can not view.' + assert rv.status_code == 403, rv.json() + assert rv.json()['message'] == 'Problem can not view.' def test_admin_can_copy_problem_from_other_course(self, forge_client): admin = utils.user.create_user(role=User.engine.Role.ADMIN) @@ -938,8 +938,8 @@ def test_override_copied_problem_status(self, forge_client): def test_publish_without_perm(self, forge_client): client_teacher = forge_client('teacher-2') rv = client_teacher.post('/problem/publish', json={'problemId': 3}) - assert rv.status_code == 403, rv.get_json() - assert rv.get_json()['message'] == 'Not the owner.' + assert rv.status_code == 403, rv.json() + assert rv.json()['message'] == 'Not the owner.' def test_publish(self, client_admin): rv = client_admin.post('/problem/publish', json={'problemId': 3}) diff --git a/tests/test_problem_stats.py b/tests/test_problem_stats.py index 7f9d7ddb..9a0d9b75 100644 --- a/tests/test_problem_stats.py +++ b/tests/test_problem_stats.py @@ -62,19 +62,18 @@ def test_get_correct_query_result_with_no_submission(context): def test_get_correct_query_result_with_multiple_status(context, status, app): problem = context['problem'] student = context['student'] - with app.app_context(): - for k, v in status.items(): - for _ in range(v): - utils.submission.create_submission(problem=problem, - user=student, - status=int(k)) - ac_user_count = 1 if status.get('0') else 0 - assert problem.get_ac_user_count() == ac_user_count - assert problem.get_tried_user_count() == 1 - submission_count = problem.get_submission_status() - assert all([ - status.get(str(k), 0) == v for k, v in submission_count.items() - ]), submission_count + for k, v in status.items(): + for _ in range(v): + utils.submission.create_submission(problem=problem, + user=student, + status=int(k)) + ac_user_count = 1 if status.get('0') else 0 + assert problem.get_ac_user_count() == ac_user_count + assert problem.get_tried_user_count() == 1 + submission_count = problem.get_submission_status() + assert all([ + status.get(str(k), 0) == v for k, v in submission_count.items() + ]), submission_count @pytest.mark.parametrize('role', ['admin', 'teacher', 'student']) @@ -82,32 +81,30 @@ def test_get_correct_stats_with_ac_submission(context, forge_client, role, app): problem = context['problem'] student = context['student'] - with app.app_context(): - utils.submission.create_submission( - problem=problem, - user=student, - status=0, - ) - client = forge_client(username=context[role].username) - rv = client.get(f'/problem/{problem.id}/stats') - assert rv.status_code == 200, rv.data - data = rv.get_json()['data'] - assert data['acUserRatio'] == [1, 1] - assert data['triedUserCount'] == 1 - assert data['scoreDistribution'] == [100] - assert data['average'] == 100 - assert data['std'] == None - assert data['statusCount'] == {'0': 1} + utils.submission.create_submission( + problem=problem, + user=student, + status=0, + ) + client = forge_client(username=context[role].username) + rv = client.get(f'/problem/{problem.id}/stats') + assert rv.status_code == 200, rv.content + data = rv.json()['data'] + assert data['acUserRatio'] == [1, 1] + assert data['triedUserCount'] == 1 + assert data['scoreDistribution'] == [100] + assert data['average'] == 100 + assert data['std'] == None + assert data['statusCount'] == {'0': 1} @pytest.mark.parametrize('role', [1, 2]) def test_clients_without_permission(context, forge_client, role, app): problem = context['problem'] user_from_other_course = utils.user.create_user(role=role) - with app.app_context(): - client = forge_client(user_from_other_course) - rv = client.get(f'/problem/{problem.id}/stats') - assert rv.status_code == 403, rv.data + client = forge_client(user_from_other_course) + rv = client.get(f'/problem/{problem.id}/stats') + assert rv.status_code == 403, rv.content @pytest.mark.parametrize('td', [ @@ -215,25 +212,24 @@ def test_multiple_student(context, forge_client, td, app): utils.user.create_user(role=2, course=course) for _ in range(len(stu_submissions) - 1) ] - with app.app_context(): - for i in range(len(stu_submissions)): - for s in stu_submissions[i]: - utils.submission.create_submission( - problem=problem, - user=students[i], - status=s['status'], - score=s['score'], - ) - client = forge_client(username=context['student'].username) - rv = client.get(f'/problem/{problem.id}/stats') - assert rv.status_code == 200, rv.data - data = rv.get_json()['data'] - assert data['acUserRatio'] == [td['ac_user'], len(students)] - assert data['triedUserCount'] == td['tried_user'] - assert sorted(data['scoreDistribution']) == sorted(td['high_scores']) - assert data['average'] == sum(td['high_scores']) / len(students) - assert math.isclose(data['std'], td['std']) - assert data['statusCount'] == td['status_count'] + for i in range(len(stu_submissions)): + for s in stu_submissions[i]: + utils.submission.create_submission( + problem=problem, + user=students[i], + status=s['status'], + score=s['score'], + ) + client = forge_client(username=context['student'].username) + rv = client.get(f'/problem/{problem.id}/stats') + assert rv.status_code == 200, rv.content + data = rv.json()['data'] + assert data['acUserRatio'] == [td['ac_user'], len(students)] + assert data['triedUserCount'] == td['tried_user'] + assert sorted(data['scoreDistribution']) == sorted(td['high_scores']) + assert data['average'] == sum(td['high_scores']) / len(students) + assert math.isclose(data['std'], td['std']) + assert data['statusCount'] == td['status_count'] def test_it_wont_count_teacher_and_admin_score(context, forge_client, app): @@ -242,95 +238,89 @@ def test_it_wont_count_teacher_and_admin_score(context, forge_client, app): student = context['student'] teacher = context['student'] admin = context['student'] - with app.app_context(): - utils.submission.create_submission( - problem=problem, - user=student, - status=0, - ) - utils.submission.create_submission( - problem=problem, - user=teacher, - status=1, - score=50, - ) - utils.submission.create_submission( - problem=problem, - user=admin, - status=0, - ) - client = forge_client(username=context['student'].username) - rv = client.get(f'/problem/{problem.id}/stats') - assert rv.status_code == 200, rv.data - data = rv.get_json()['data'] - assert data['acUserRatio'] == [1, 1] - assert data['triedUserCount'] == 1 - assert data['average'] == 100 - assert data['std'] == None - assert data['scoreDistribution'] == [100] - assert data['statusCount'] == {'0': 2, '1': 1} + utils.submission.create_submission( + problem=problem, + user=student, + status=0, + ) + utils.submission.create_submission( + problem=problem, + user=teacher, + status=1, + score=50, + ) + utils.submission.create_submission( + problem=problem, + user=admin, + status=0, + ) + client = forge_client(username=context['student'].username) + rv = client.get(f'/problem/{problem.id}/stats') + assert rv.status_code == 200, rv.content + data = rv.json()['data'] + assert data['acUserRatio'] == [1, 1] + assert data['triedUserCount'] == 1 + assert data['average'] == 100 + assert data['std'] == None + assert data['scoreDistribution'] == [100] + assert data['statusCount'] == {'0': 2, '1': 1} def test_top_10_runtime_submissions(context, forge_client, app): problem = context['problem'] student = context['student'] - with app.app_context(): - runtimes = [ - 0, 0, 2, 5, 10, 100, 200, 300, 400, 500, 700, 800, 900, 1000 - ] - shuffled_runtimes = runtimes.copy() - shuffle(shuffled_runtimes) - for v in shuffled_runtimes: - utils.submission.create_submission(problem=problem, - user=student, - status=0, - exec_time=v) - client = forge_client(username=context['student'].username) - rv = client.get(f'/problem/{problem.id}/stats') - assert rv.status_code == 200, rv.data - data = rv.get_json()['data'] - top_10_runtimes = [s['runTime'] for s in data['top10RunTime']] - assert top_10_runtimes == runtimes[:10] + runtimes = [0, 0, 2, 5, 10, 100, 200, 300, 400, 500, 700, 800, 900, 1000] + shuffled_runtimes = runtimes.copy() + shuffle(shuffled_runtimes) + for v in shuffled_runtimes: + utils.submission.create_submission(problem=problem, + user=student, + status=0, + exec_time=v) + client = forge_client(username=context['student'].username) + rv = client.get(f'/problem/{problem.id}/stats') + assert rv.status_code == 200, rv.content + data = rv.json()['data'] + top_10_runtimes = [s['runTime'] for s in data['top10RunTime']] + assert top_10_runtimes == runtimes[:10] def test_top_10_memory_submissions(context, forge_client, app): problem = context['problem'] student = context['student'] - with app.app_context(): - memory = [0, 0, 2, 5, 10, 100, 200, 300, 400, 500, 700, 800, 900, 1000] - shuffled_memory = memory.copy() - shuffle(shuffled_memory) - for v in shuffled_memory: - utils.submission.create_submission(problem=problem, - user=student, - status=0, - memory_usage=v) - client = forge_client(username=context['student'].username) - rv = client.get(f'/problem/{problem.id}/stats') - assert rv.status_code == 200, rv.data - data = rv.get_json()['data'] - top_10_memory = [s['memoryUsage'] for s in data['top10MemoryUsage']] - assert top_10_memory == memory[:10] + memory = [0, 0, 2, 5, 10, 100, 200, 300, 400, 500, 700, 800, 900, 1000] + shuffled_memory = memory.copy() + shuffle(shuffled_memory) + for v in shuffled_memory: + utils.submission.create_submission(problem=problem, + user=student, + status=0, + memory_usage=v) + client = forge_client(username=context['student'].username) + rv = client.get(f'/problem/{problem.id}/stats') + assert rv.status_code == 200, rv.content + data = rv.json()['data'] + top_10_memory = [s['memoryUsage'] for s in data['top10MemoryUsage']] + assert top_10_memory == memory[:10] def test_cached_highscore(context, forge_client, app): problem = context['problem'] student = context['student'] - with app.app_context(): - utils.submission.create_submission(problem=problem, - user=student, - status=1, - score=50) - client = forge_client(username=context['student'].username) - rv = client.get(f'/problem/{problem.id}/stats') - assert rv.status_code == 200, rv.data - data = rv.get_json()['data'] - assert data['scoreDistribution'] == [50] + utils.submission.create_submission(problem=problem, + user=student, + status=1, + score=50) + client = forge_client(username=context['student'].username) + rv = client.get(f'/problem/{problem.id}/stats') + assert rv.status_code == 200, rv.content + data = rv.json()['data'] + assert data['scoreDistribution'] == [50] - cached_rv = client.get(f'/problem/{problem.id}/stats') - assert cached_rv.status_code == 200, cached_rv.data - cached_data = cached_rv.get_json()['data'] - assert cached_data['scoreDistribution'] == [50] + cached_rv = client.get(f'/problem/{problem.id}/stats') + assert cached_rv.status_code == 200, cached_rv.content + cached_data = cached_rv.json()['data'] + assert cached_data['scoreDistribution'] == [50] # def test_performance(context, forge_client, app): @@ -348,5 +338,5 @@ def test_cached_highscore(context, forge_client, app): # ) # client = forge_client(username=context['student'].username) # rv = client.get(f'/problem/{problem.id}/stats') -# assert rv.status_code == 200, rv.data +# assert rv.status_code == 200, rv.content # assert 0 diff --git a/tests/test_profile.py b/tests/test_profile.py index a0d1e176..c51ec2f5 100644 --- a/tests/test_profile.py +++ b/tests/test_profile.py @@ -6,7 +6,7 @@ class TestProfile(BaseTester): def test_edit_without_displayName(self, client_student): rv = client_student.post('/profile', json={'bio': 'Hello World!'}) - json = rv.get_json() + json = rv.json() assert rv.status_code == 200 assert json['status'] == 'ok' assert json['message'] == 'Uploaded.' @@ -14,7 +14,7 @@ def test_edit_without_displayName(self, client_student): def test_edit_without_bio(self, client_student): rv = client_student.post('/profile', json={'displayedName': 'aisu_0911'}) - json = rv.get_json() + json = rv.json() assert rv.status_code == 200 assert json['status'] == 'ok' assert json['message'] == 'Uploaded.' @@ -25,14 +25,14 @@ def test_edit(self, client_student): 'displayedName': 'aisu_0911', 'bio': 'Hello World!' }) - json = rv.get_json() + json = rv.json() assert rv.status_code == 200 assert json['status'] == 'ok' assert json['message'] == 'Uploaded.' def test_view_without_username(self, client_student): rv = client_student.get('/profile') - json = rv.get_json() + json = rv.json() assert rv.status_code == 200 assert json['status'] == 'ok' assert json['message'] == 'Profile exist.' @@ -41,7 +41,7 @@ def test_view_without_username(self, client_student): def test_view_with_username(self, client_student): rv = client_student.get('/profile/student') - json = rv.get_json() + json = rv.json() assert rv.status_code == 200 assert json['status'] == 'ok' assert json['message'] == 'Profile exist.' @@ -50,7 +50,7 @@ def test_view_with_username(self, client_student): def test_view_with_nonexist_username(self, client_student): rv = client_student.get('/profile/a_username_not_exist') - json = rv.get_json() + json = rv.json() assert rv.status_code == 404 assert json['status'] == 'err' assert json['message'] == 'Profile not exist.' @@ -64,7 +64,7 @@ def test_set_config(self, client_student): 'tabSize': 4, 'language': 0 }) - json = rv.get_json() + json = rv.json() assert rv.status_code == 200 assert json['message'] == 'Uploaded.' @@ -77,7 +77,7 @@ def test_set_invalid_config(self, client_student): 'tabSize': 4, 'language': 0 }) - json = rv.get_json() + json = rv.json() assert rv.status_code == 400 assert json['status'] == 'err' assert json['message'] == 'Update fail.' diff --git a/tests/test_ranking.py b/tests/test_ranking.py index e25322a8..220b879e 100644 --- a/tests/test_ranking.py +++ b/tests/test_ranking.py @@ -22,10 +22,10 @@ def test_get( 'problemId': pid, 'languageType': 0 }) - print(rv.get_json()) + print(rv.json()) rv = client.get('/ranking') - json = rv.get_json() + json = rv.json() assert json['message'] == 'Success.' assert rv.status_code == 200 ''' diff --git a/tests/test_scoreboard.py b/tests/test_scoreboard.py index 3e5e7795..e7f47675 100644 --- a/tests/test_scoreboard.py +++ b/tests/test_scoreboard.py @@ -172,21 +172,20 @@ def test_get_correct_query_result_with_single_problem(context, testcase, app): students = [utils.user.create_user(role=2, username=u) for u in students] course = utils.course.create_course(students=students) problem = utils.problem.create_problem(course=course, owner=course.teacher) - with app.app_context(): - for username, scores in submissions.items(): - for score in scores: - utils.submission.create_submission( - user=username, - problem=problem, - score=score, - ) - data = course.get_scoreboard([problem.id]) - assert len(data) == len(students), data - output = [{ - **item, 'user': User(item['user']).info - } for item in testcase['output']] - data = sorted(data, key=lambda x: x['user']['username']) - assert data == output + for username, scores in submissions.items(): + for score in scores: + utils.submission.create_submission( + user=username, + problem=problem, + score=score, + ) + data = course.get_scoreboard([problem.id]) + assert len(data) == len(students), data + output = [{ + **item, 'user': User(item['user']).info + } for item in testcase['output']] + data = sorted(data, key=lambda x: x['user']['username']) + assert data == output @pytest.mark.parametrize('testcase', [ @@ -314,24 +313,23 @@ def test_get_correct_query_result_with_multiple_problems( course = utils.course.create_course(students=students) for _ in range(len(problems)): utils.problem.create_problem(course=course, owner=course.teacher) - with app.app_context(): - for username, subs in submissions.items(): - for s in subs: - params = { - param.split('=')[0]: int(param.split('=')[1]) - for param in s.split('&') - } - utils.submission.create_submission( - user=username, - **params, - ) - data = course.get_scoreboard(problems) - assert len(data) == len(students), data - output = [{ - **item, 'user': User(item['user']).info - } for item in testcase['output']] - data = sorted(data, key=lambda x: x['user']['username']) - assert data == output + for username, subs in submissions.items(): + for s in subs: + params = { + param.split('=')[0]: int(param.split('=')[1]) + for param in s.split('&') + } + utils.submission.create_submission( + user=username, + **params, + ) + data = course.get_scoreboard(problems) + assert len(data) == len(students), data + output = [{ + **item, 'user': User(item['user']).info + } for item in testcase['output']] + data = sorted(data, key=lambda x: x['user']['username']) + assert data == output t1 = 1648832400 @@ -682,46 +680,42 @@ def test_get_correct_query_result_with_multiple_problems_and_range( course = utils.course.create_course(students=students) for _ in range(len(problems)): utils.problem.create_problem(course=course, owner=course.teacher) - with app.app_context(): - for username, subs in submissions.items(): - for s in subs: - params = { - param.split('=')[0]: int(param.split('=')[1]) - for param in s.split('&') - } - s = utils.submission.create_submission( - user=username, - **params, - ) - if username == 'eve': - print(s.timestamp) - data = course.get_scoreboard(problems, testcase['input']['start'], - testcase['input']['end']) - assert len(data) == len(students), data - output = [{ - **item, 'user': User(item['user']).info - } for item in testcase['output']] - data = sorted(data, key=lambda x: x['user']['username']) - assert data == output, data + for username, subs in submissions.items(): + for s in subs: + params = { + param.split('=')[0]: int(param.split('=')[1]) + for param in s.split('&') + } + s = utils.submission.create_submission( + user=username, + **params, + ) + if username == 'eve': + print(s.timestamp) + data = course.get_scoreboard(problems, testcase['input']['start'], + testcase['input']['end']) + assert len(data) == len(students), data + output = [{ + **item, 'user': User(item['user']).info + } for item in testcase['output']] + data = sorted(data, key=lambda x: x['user']['username']) + assert data == output, data @pytest.mark.parametrize('query', [{}, {'pids': ''}]) def test_get_error_for_no_provided_pids(context, forge_client, query: Dict, app): - with app.app_context(): - client = forge_client(username=context['student'].username) - qs = '&'.join(f'{k}={v}' for k, v in query.items()) - rv = client.get( - f'/course/{context["course"].course_name}/scoreboard?{qs}') - assert rv.status_code == 400 + client = forge_client(username=context['student'].username) + qs = '&'.join(f'{k}={v}' for k, v in query.items()) + rv = client.get(f'/course/{context["course"].course_name}/scoreboard?{qs}') + assert rv.status_code == 400 @pytest.mark.parametrize('pids', ['a', '1,a', '1,2,a', None, 'None', '1,']) def test_get_error_for_providing_unparsable_pids(context, forge_client, pids: str, app): - with app.app_context(): - client = forge_client(username=context['admin'].username) - rv = client.get( - f'/course/{context["course"].course_name}/scoreboard?pids={pids}') - assert rv.status_code == 400 - assert rv.json['message'] == 'Error occurred when parsing `pids`.' + client = forge_client(username=context['admin'].username) + rv = client.get( + f'/course/{context["course"].course_name}/scoreboard?pids={pids}') + assert rv.status_code == 400 + assert rv.json()['message'] == 'Error occurred when parsing `pids`.' diff --git a/tests/test_submission.py b/tests/test_submission.py index 65e74cfa..0cf4e2f1 100644 --- a/tests/test_submission.py +++ b/tests/test_submission.py @@ -114,7 +114,7 @@ def test_get_truncated_submission_list(self, forge_client, offset, count): rv, rv_json, rv_data = BaseTester.request( client, 'get', - f'/submission/?offset={offset}&count={count}', + f'/submission?offset={offset}&count={count}', ) assert rv.status_code == 200, rv_json assert len(rv_data['submissions']) == 1 @@ -124,7 +124,7 @@ def test_get_submission_list_with_maximun_offset(self, forge_client): rv, rv_json, rv_data = BaseTester.request( client, 'get', - f'/submission/?offset={self.init_submission_count}&count=1', + f'/submission?offset={self.init_submission_count}&count=1', ) assert rv.status_code == 200, rv_json assert len(rv_data['submissions']) == 0, rv_data @@ -134,7 +134,7 @@ def test_get_all_submission(self, forge_client): rv, rv_json, rv_data = BaseTester.request( client, 'get', - '/submission/?offset=0&count=-1', + '/submission?offset=0&count=-1', ) assert rv.status_code == 200, rv_json @@ -145,7 +145,7 @@ def test_get_all_submission(self, forge_client): rv, rv_json, rv_data = BaseTester.request( client, 'get', - f'/submission/?offset={offset}&count=-1', + f'/submission?offset={offset}&count=-1', ) assert rv.status_code == 200, rv_json @@ -158,7 +158,7 @@ def test_get_submission_list_over_db_size(self, forge_client): rv, rv_json, rv_data = BaseTester.request( client, 'get', - f'/submission/?offset=0&count={self.init_submission_count ** 2}', + f'/submission?offset=0&count={self.init_submission_count ** 2}', ) assert rv.status_code == 200, rv_json @@ -167,7 +167,7 @@ def test_get_submission_list_over_db_size(self, forge_client): def test_get_submission_without_login(self, client): for _id in self.submissions.values(): rv = client.get(f'/submission/{_id}') - pprint(rv.get_json()) + pprint(rv.json()) assert rv.status_code == 403, client.cookie_jar def test_normal_user_get_others_submission(self, forge_client): @@ -227,7 +227,7 @@ def test_get_submission_list_with_out_ranged_negative_arg( rv, rv_json, rv_data = BaseTester.request( client, 'get', - f'/submission/?offset={offset}&count={count}', + f'/submission?offset={offset}&count={count}', ) assert rv.status_code == 400 @@ -249,7 +249,7 @@ def test_get_submission_list_by_filter( rv, rv_json, rv_data = BaseTester.request( client, 'get', - f'/submission/?offset=0&count=-1&{key}={except_val}', + f'/submission?offset=0&count=-1&{key}={except_val}', ) assert rv.status_code == 200, rv_json @@ -266,15 +266,15 @@ def test_get_submission_list_by_course_filter( rv, rv_json, rv_data = BaseTester.request( client, 'get', - f'/submission/?offset=0&count=-1&course=aaa', + f'/submission?offset=0&count=-1&course=aaa', ) # No submissions found cause "aaa" doesn't exist - assert rv.status_code == 200, rv.get_json() + assert rv.status_code == 200, rv.json() assert len(rv_data['submissions']) == 0 rv, rv_json, rv_data = BaseTester.request( client, 'get', - f'/submission/?offset=0&count=-1&course={self.courses[0]}', + f'/submission?offset=0&count=-1&course={self.courses[0]}', ) assert rv.status_code == 200 assert len(rv_data['submissions']) == 2 @@ -458,15 +458,15 @@ def test_normal_submission( # my submission will send to sandbox to be judged files = { 'code': ( + f'base{ext}', get_source(f'base{ext}'), - 'code', ) } rv = client.put( f'/submission/{rv_data["submissionId"]}', - data=files, + files=files, ) - rv_json = rv.get_json() + rv_json = rv.json() assert rv.status_code == 200, rv_json def test_user_db_submission_field_content( @@ -507,15 +507,15 @@ def test_wrong_language_type( ) files = { 'code': ( + 'base.c', get_source('base.c'), - 'code', ) } rv = client.put( f'/submission/{rv_data["submissionId"]}', - data=files, + files=files, ) - rv_json = rv.get_json() + rv_json = rv.json() # file extension doesn't equal we claimed before assert rv.status_code == 400, rv_json @@ -530,16 +530,16 @@ def test_wrong_file_type(self, forge_client, get_source, problem_ids): ) files = { 'code': ( + 'main2.pdf', get_source('main2.pdf'), - 'code', ) } print(rv_json) rv = client.put( f'/submission/{rv_data["submissionId"]}', - data=files, + files=files, ) - rv_json = rv.get_json() + rv_json = rv.json() # file is not PDF assert rv.status_code == 400, rv_json @@ -556,12 +556,12 @@ def test_empty_source( json=self.post_payload(), ) - files = {'code': (None, 'code')} + files = {'code': ('code', b'')} rv = client.put( f'/submission/{rv_data["submissionId"]}', - data=files, + files=files, ) - rv_json = rv.get_json() + rv_json = rv.json() assert rv.status_code == 400, rv_json @@ -587,10 +587,10 @@ def test_no_source_upload( json=self.post_payload(), ) assert rv.status_code == 200, rv_data - files = {'c0d3': (get_source(f'base{ext}'), 'code')} + files = {'c0d3': (f'base{ext}', get_source(f'base{ext}'))} rv = client.put( f'/submission/{rv_data["submissionId"]}', - data=files, + files=files, ) assert rv.status_code == 400, rv_data @@ -612,15 +612,15 @@ def test_submit_to_others( submission_id = rv_data['submissionId'] files = { 'code': ( - get_source('base.cpp'), 'd1w5q6dqw', + get_source('base.cpp'), ) } rv, rv_json, rv_data = BaseTester.request( client, 'put', f'/submission/{submission_id}', - data=files, + files=files, ) assert rv.status_code == 403, rv_json @@ -640,7 +640,7 @@ def test_reach_rate_limit(self, client_student): json=post_json, ) - assert rv.status_code == 429, rv.get_json() + assert rv.status_code == 429, rv.json() # recover rate limit Submission.config().update(rate_limit=0) @@ -658,17 +658,17 @@ def test_reach_quota(self, problem_ids, forge_client, user, response): '/submission', json=post_json, ) - assert rv.status_code == 200, (i, rv.get_json()) + assert rv.status_code == 200, (i, rv.json()) rv = client.get(f'/problem/view/{pid}') assert rv.status_code == 200 - assert rv.get_json()['data']['submitCount'] == 10 + assert rv.json()['data']['submitCount'] == 10 rv = client.post( '/submission', json=post_json, ) - assert rv.status_code == response, rv.get_json() + assert rv.status_code == response, rv.json() def test_normally_rejudge(self, forge_client, submit_once): submission_id = submit_once('student', self.pid, 'base.c', 0) @@ -704,10 +704,10 @@ def test_reach_file_size_limit( client, 'put', f'/submission/{submission_id}', - data={ + files={ 'code': ( - get_source('big.c'), 'aaaaa', + get_source('big.c'), ), }, ) @@ -748,10 +748,10 @@ def test_reupload_code_should_fail( client, 'put', f'/submission/{submission_id}', - data={ + files={ 'code': ( - get_source('base.c'), 'code', + get_source('base.c'), ), }, ) @@ -811,59 +811,59 @@ def test_handwritten_submission(self, client_student, client_teacher): pdf_dir = pathlib.Path('tests/handwritten/main.pdf.zip') files = { 'code': ( - open(pdf_dir, 'rb'), 'code', + open(pdf_dir, 'rb'), ) } rv = client_student.put( f'/submission/{self.submission_id}', - data=files, + files=files, ) - rv_json = rv.get_json() + rv_json = rv.json() assert rv.status_code == 200, rv_json # third, read the student's upload rv = client_student.get(f'/submission/{self.submission_id}/pdf/upload') - assert rv.status_code == 200, rv.get_json() + assert rv.status_code == 200, rv.json() # fourth, grade the submission rv = client_teacher.put( f'/submission/{self.submission_id}/grade', json={'score': 87}, ) - assert rv.status_code == 200, rv.get_json() + assert rv.status_code == 200, rv.json() # fifth, send a wrong file to the submission pdf_dir = pathlib.Path('tests/src/base.c') files = { 'comment': ( - open(pdf_dir, 'rb'), 'comment', + open(pdf_dir, 'rb'), ) } rv = client_teacher.put( f'/submission/{self.submission_id}/comment', - data=files, + files=files, ) - assert rv.status_code == 400, rv.get_json() + assert rv.status_code == 400, rv.json() # sixth, send the comment.pdf to the submission pdf_dir = pathlib.Path('tests/handwritten/comment.pdf') files = { 'comment': ( - open(pdf_dir, 'rb'), 'comment', + open(pdf_dir, 'rb'), ) } rv = client_teacher.put( f'/submission/{self.submission_id}/comment', - data=files, + files=files, ) - assert rv.status_code == 200, rv.get_json() + assert rv.status_code == 200, rv.json() # seventh, get the submission info rv = client_student.get(f'/submission/{self.submission_id}') - rv_json = rv.get_json() + rv_json = rv.json() assert rv.status_code == 200, rv_json assert rv_json['data']['score'] == 87 @@ -883,24 +883,24 @@ def test_handwritten_submission(self, client_student, client_teacher): pdf_dir = pathlib.Path('tests/handwritten/main.pdf.zip') files = { 'code': ( - open(pdf_dir, 'rb'), 'code', + open(pdf_dir, 'rb'), ) } rv = client_student.put( f'/submission/{self.submission_id}', - data=files, + files=files, ) - assert rv.status_code == 200, rv.get_json() + assert rv.status_code == 200, rv.json() # see if the student and thw teacher can get the submission rv = client_student.get(f'/submission?offset=0&count=-1') - rv_json = rv.get_json() + rv_json = rv.json() assert rv.status_code == 200, rv_json assert len(rv_json['data']['submissions']) == 1 rv = client_teacher.get(f'/submission?offset=0&count=-1') - rv_json = rv.get_json() + rv_json = rv.json() assert rv.status_code == 200, rv_json assert len(rv_json['data']['submissions']) == 1 @@ -956,8 +956,8 @@ def test_update_existing_comment( assert rv.status_code == 200, rv_json # check comment content rv = client.get(f'/submission/{submission_id}/pdf/comment') - assert rv.status_code == 200, rv.get_json() - assert rv.data == open(p, 'rb').read() + assert rv.status_code == 200, rv.json() + assert rv.content == open(p, 'rb').read() def test_comment_for_different_submissions( self, @@ -990,14 +990,14 @@ def test_comment_for_different_submissions( f'/submission/{submission_id}/pdf/comment', ) assert rv.status_code == 200, rv_json - assert rv.data == open(p, 'rb').read(), p + assert rv.content == open(p, 'rb').read(), p class TestSubmissionConfig(SubmissionTester): def test_get_config(self, client_admin): rv = client_admin.get(f'/submission/config') - json = rv.get_json() + json = rv.json() assert rv.status_code == 200 def test_edit_config(self, client_admin): @@ -1013,10 +1013,10 @@ def test_edit_config(self, client_admin): }] }, ) - json = rv.get_json() + json = rv.json() assert rv.status_code == 200, json rv = client_admin.get(f'/submission/config') - json = rv.get_json() + json = rv.json() assert rv.status_code == 200, json assert json['data'] == { 'rateLimit': @@ -1037,16 +1037,15 @@ def test_student_cannot_view_WA_submission_output(forge_client, app): task_len=1, )) WA = 1 - with app.app_context(): - submission = utils.submission.create_submission( - user=student, - problem=problem, - status=WA, - ) - utils.submission.add_fake_output(submission) + submission = utils.submission.create_submission( + user=student, + problem=problem, + status=WA, + ) + utils.submission.add_fake_output(submission) client = forge_client(student.username) rv = client.get(f'/submission/{submission.id}/output/0/0') - assert rv.status_code == 403, rv.get_json() + assert rv.status_code == 403, rv.json() def test_student_can_view_CE_submission_output(forge_client, app): @@ -1057,30 +1056,28 @@ def test_student_can_view_CE_submission_output(forge_client, app): task_len=1, )) CE = 2 - with app.app_context(): - submission = utils.submission.create_submission( - user=student, - problem=problem, - status=CE, - ) - utils.submission.add_fake_output(submission) + submission = utils.submission.create_submission( + user=student, + problem=problem, + status=CE, + ) + utils.submission.add_fake_output(submission) client = forge_client(student.username) rv = client.get(f'/submission/{submission.id}/output/0/0') - assert rv.status_code == 200, rv.get_json() + assert rv.status_code == 200, rv.json() expected = submission.get_single_output(0, 0) - assert expected == rv.get_json()['data'] + assert expected == rv.json()['data'] def test_cannot_view_output_out_of_index(app, forge_client): - with app.app_context(): - user = utils.user.create_user() - course = utils.course.create_course() - problem = utils.problem.create_problem(course=course) - submission = utils.submission.create_submission( - user=user, - problem=problem, - ) + user = utils.user.create_user() + course = utils.course.create_course() + problem = utils.problem.create_problem(course=course) + submission = utils.submission.create_submission( + user=user, + problem=problem, + ) client = forge_client(course.teacher.username) rv = client.get(f'/submission/{submission.id}/output/100/100') - assert rv.status_code == 400, rv.get_json() - assert rv.get_json()['message'] == 'task not exist' + assert rv.status_code == 400, rv.json() + assert rv.json()['message'] == 'task not exist' diff --git a/tests/test_test.py b/tests/test_test.py index 7c30d8a6..d52f2ed8 100644 --- a/tests/test_test.py +++ b/tests/test_test.py @@ -51,7 +51,8 @@ def test_role(forge_client, role): LOG_LEVELS, ) def test_log(forge_client, caplog, app, log_level): - app.logger.setLevel(log_level) + import logging + logging.getLogger('model.test').setLevel(log_level) client = forge_client('first_admin') rv, rv_json, _ = BaseTester.request( client, @@ -63,11 +64,11 @@ def test_log(forge_client, caplog, app, log_level): idx = LOG_LEVELS.index(log_level) expected_log = [ - 'DEBUG app:test.py:25 this is a DEBUG log', - 'INFO app:test.py:26 this is a INFO log', - 'WARNING app:test.py:27 this is a WARNING log', - 'ERROR app:test.py:28 this is a ERROR log', - 'CRITICAL app:test.py:29 this is a CRITICAL log', + 'DEBUG model.test:test.py:26 this is a DEBUG log', + 'INFO model.test:test.py:27 this is a INFO log', + 'WARNING model.test:test.py:28 this is a WARNING log', + 'ERROR model.test:test.py:29 this is a ERROR log', + 'CRITICAL model.test:test.py:30 this is a CRITICAL log', ][idx:] assert caplog.text == '\n'.join(expected_log) + '\n', caplog.text diff --git a/tests/test_use_announcement.py b/tests/test_use_announcement.py index 0f42e4f3..87420b20 100644 --- a/tests/test_use_announcement.py +++ b/tests/test_use_announcement.py @@ -9,8 +9,8 @@ class TestAnnouncement(BaseTester): def test_get_list_with_course_does_not_exist(self, client_student): rv = client_student.get('/ann/CourseDoesNotExist/ann') - assert rv.status_code == 200, rv.get_json() - assert rv.get_json()['data'] == [] + assert rv.status_code == 200, rv.json() + assert rv.json()['data'] == [] def test_get_invalid_announcement_list(self, client_student, monkeypatch): @@ -20,14 +20,14 @@ def mock_ann_list_raise_does_not_exist(*args): monkeypatch.setattr(ModelAnn, 'ann_list', mock_ann_list_raise_does_not_exist) rv = client_student.get('/ann/Public/ann') - assert rv.status_code == 403, rv.get_json() - assert rv.get_json()['message'] == 'Cannot Access a Announcement' + assert rv.status_code == 403, rv.json() + assert rv.json()['message'] == 'Cannot Access a Announcement' def test_get_none_announcement_list(self, client_student, monkeypatch): monkeypatch.setattr(ModelAnn, 'ann_list', lambda *_: None) rv = client_student.get('/ann/Public/ann') - assert rv.status_code == 404, rv.get_json() - assert rv.get_json()['message'] == 'Announcement Not Found' + assert rv.status_code == 404, rv.json() + assert rv.json()['message'] == 'Announcement Not Found' def test_add_invalid(self, client_student, monkeypatch): @@ -42,8 +42,8 @@ def mock_new_ann_reaise_validation_error(*args, **kwargs): 'markdown': 'im bad', 'courseName': 'invalid' }) - assert rv.status_code == 400, rv.get_json() - assert rv.get_json()['message'] == 'Failed to Create Announcement' + assert rv.status_code == 400, rv.json() + assert rv.json()['message'] == 'Failed to Create Announcement' def test_add(self, client_admin): # add an announcement @@ -60,11 +60,11 @@ def test_add(self, client_admin): 'markdown': 'im good', 'courseName': 'math' }) - json = rv.get_json() + json = rv.json() assert rv.status_code == 200 rv = client_admin.get(f'/course/math/ann') - json = rv.get_json() + json = rv.json() assert rv.status_code == 200 assert len(json['data']) == 1 assert json['data'][0]['title'] == 'lol' @@ -79,13 +79,13 @@ def test_add_without_teacher(self, client_student): 'markdown': 'god', 'courseName': 'math' }) - json = rv.get_json() + json = rv.json() assert rv.status_code == 403 def test_get_list_without_perm(self, client_student): rv = client_student.get('/ann/math/ann') - assert rv.status_code == 200, rv.get_json() - assert rv.get_json()['data'] == [] + assert rv.status_code == 200, rv.json() + assert rv.json()['data'] == [] def test_edit_does_not_exist(self, client_admin): rv = client_admin.put('/ann', @@ -95,13 +95,13 @@ def test_edit_does_not_exist(self, client_admin): 'markdown': 'im good', 'pinned': True }) - assert rv.status_code == 404, rv.get_json() - assert rv.get_json()['message'] == 'Announcement Not Found' + assert rv.status_code == 404, rv.json() + assert rv.json()['message'] == 'Announcement Not Found' def test_edit_without_perm(self, forge_client): client = forge_client('admin') rv = client.get(f'/course/math/ann') - id = rv.get_json()['data'][0]['annId'] + id = rv.json()['data'][0]['annId'] client = forge_client('student') rv = client.put('/ann', json={ @@ -110,12 +110,12 @@ def test_edit_without_perm(self, forge_client): 'markdown': 'im good', 'pinned': True }) - assert rv.status_code == 403, rv.get_json() - assert rv.get_json()['message'] == 'Failed to Update Announcement' + assert rv.status_code == 403, rv.json() + assert rv.json()['message'] == 'Failed to Update Announcement' def test_edit_with_invalid_ann_content(self, client_admin, monkeypatch): rv = client_admin.get(f'/course/math/ann') - id = rv.get_json()['data'][0]['annId'] + id = rv.json()['data'][0]['annId'] rv = client_admin.put('/ann', json={ 'annId': id, @@ -124,13 +124,13 @@ def test_edit_with_invalid_ann_content(self, client_admin, monkeypatch): 'markdown': 'im good', 'pinned': True }) - assert rv.status_code == 400, rv.get_json() - assert rv.get_json()['message'] == 'Failed to Update Announcement' + assert rv.status_code == 400, rv.json() + assert rv.json()['message'] == 'Failed to Update Announcement' def test_edit(self, client_admin): # edit an announcement rv = client_admin.get(f'/course/math/ann') - json = rv.get_json() + json = rv.json() id = json['data'][0]['annId'] rv = client_admin.put('/ann', @@ -140,46 +140,47 @@ def test_edit(self, client_admin): 'markdown': 'im good', 'pinned': True }) - json = rv.get_json() + json = rv.json() assert rv.status_code == 200 rv = client_admin.get(f'/ann/math/{id}') - json = rv.get_json() + json = rv.json() assert rv.status_code == 200 assert json['data'][0]['title'] == 'lol (edit)' assert json['data'][0]['markdown'] == 'im good' assert json['data'][0]['pinned'] == True def test_delete_does_not_exist(self, client_admin): - rv = client_admin.delete('/ann', - json={ - 'annId': '000000000000000000000000', - }) - assert rv.status_code == 404, rv.get_json() - assert rv.get_json()['message'] == 'Announcement Not Found' + rv = client_admin.request("DELETE", + '/ann', + json={ + 'annId': '000000000000000000000000', + }) + assert rv.status_code == 404, rv.json() + assert rv.json()['message'] == 'Announcement Not Found' def test_delete_without_perm(self, forge_client): client = forge_client('admin') rv = client.get(f'/course/math/ann') - id = rv.get_json()['data'][0]['annId'] + id = rv.json()['data'][0]['annId'] client = forge_client('student') - rv = client.delete('/ann', json={ + rv = client.request("DELETE", '/ann', json={ 'annId': id, }) - assert rv.status_code == 403, rv.get_json() - assert rv.get_json()['message'] == 'Failed to Delete Announcement' + assert rv.status_code == 403, rv.json() + assert rv.json()['message'] == 'Failed to Delete Announcement' def test_delete(self, client_admin): # delete an announcement rv = client_admin.get(f'/course/math/ann') - json = rv.get_json() + json = rv.json() id = json['data'][0]['annId'] - rv = client_admin.delete('/ann', json={'annId': id}) + rv = client_admin.request("DELETE", '/ann', json={'annId': id}) assert rv.status_code == 200 rv = client_admin.get(f'/course/math/ann') - json = rv.get_json() + json = rv.json() assert rv.status_code == 200 assert len(json['data']) == 0 diff --git a/tests/test_utils_request.py b/tests/test_utils_request.py deleted file mode 100644 index adaf0dd8..00000000 --- a/tests/test_utils_request.py +++ /dev/null @@ -1,193 +0,0 @@ -from tests.base_tester import BaseTester -from model.utils.request import Request, parse_body, parse_query -from model.schemas.base import BaseSchema -from mongo import Announcement -from typing import Optional - - -class MockHTTPError(tuple): - - def __new__(cls, *args, **kwargs): - return super().__new__(tuple, args) - - -class LoginBody(BaseSchema): - username: str - password: str - - -class SearchQuery(BaseSchema): - q: Optional[str] = None - limit: Optional[int] = None - - -class TestParseBody(BaseTester): - - def test_parse_body_valid(self, monkeypatch): - - class MockRequest: - - @staticmethod - def get_json(silent=False): - return {'username': 'alice', 'password': 'secret'} - - from model.utils import request as req_module - monkeypatch.setattr(req_module, 'request', MockRequest()) - monkeypatch.setattr(req_module, 'HTTPError', MockHTTPError) - - @parse_body(LoginBody) - def route(body): - return body - - result = route() - assert result.username == 'alice' - assert result.password == 'secret' - - def test_parse_body_invalid_type(self, monkeypatch): - - class MockRequest: - - @staticmethod - def get_json(silent=False): - return {'username': 123, 'password': 'secret'} - - # username must be str — Pydantic v2 does NOT coerce int to str - from model.utils import request as req_module - monkeypatch.setattr(req_module, 'request', MockRequest()) - monkeypatch.setattr(req_module, 'HTTPError', MockHTTPError) - - @parse_body(LoginBody) - def route(body): - return body - - # Pydantic v2 rejects int for a str field - message, status_code = route() - assert status_code == 400 - assert message == 'Invalid request body' - - def test_parse_body_missing_required_field(self, monkeypatch): - - class MockRequest: - - @staticmethod - def get_json(silent=False): - return {'username': 'alice'} # missing 'password' - - from model.utils import request as req_module - monkeypatch.setattr(req_module, 'request', MockRequest()) - monkeypatch.setattr(req_module, 'HTTPError', MockHTTPError) - - @parse_body(LoginBody) - def route(body): - return body - - message, status_code = route() - assert status_code == 400 - assert message == 'Invalid request body' - - def test_parse_body_no_json(self, monkeypatch): - - class MockRequest: - - @staticmethod - def get_json(silent=False): - return None # no JSON body - - from model.utils import request as req_module - monkeypatch.setattr(req_module, 'request', MockRequest()) - monkeypatch.setattr(req_module, 'HTTPError', MockHTTPError) - - @parse_body(LoginBody) - def route(body): - return body - - message, status_code = route() - assert status_code == 400 - - def test_parse_body_camelcase_alias(self, monkeypatch): - from model.schemas.profile import EditProfileBody - - class MockRequest: - - @staticmethod - def get_json(silent=False): - return {'displayedName': 'Alice'} # camelCase alias - - from model.utils import request as req_module - monkeypatch.setattr(req_module, 'request', MockRequest()) - - @parse_body(EditProfileBody) - def route(body): - return body - - result = route() - assert result.displayed_name == 'Alice' - - -class TestParseQuery(BaseTester): - - def test_parse_query_valid(self, monkeypatch): - - class MockRequest: - args = {'q': 'hello', 'limit': '10'} - - from model.utils import request as req_module - monkeypatch.setattr(req_module, 'request', MockRequest()) - - @parse_query(SearchQuery) - def route(query): - return query - - result = route() - assert result.q == 'hello' - assert result.limit == 10 - - def test_parse_query_optional_missing(self, monkeypatch): - - class MockRequest: - args = {} - - from model.utils import request as req_module - monkeypatch.setattr(req_module, 'request', MockRequest()) - - @parse_query(SearchQuery) - def route(query): - return query - - result = route() - assert result.q is None - assert result.limit is None - - -class TestUtilsRequestDoc(BaseTester): - - def test_request_doc_with_missing_args(self, monkeypatch): - - @Request.doc('src', 'dst', str) - def route(dst): - assert type(dst) == str - - from model.utils import request - monkeypatch.setattr(request, 'HTTPError', MockHTTPError) - message, status_code = route(typo=123) - assert status_code == 500 - assert message == 'src not found in function argument' - - def test_request_doc_raise_validation_error(self, monkeypatch): - - @Request.doc('src', 'dst', Announcement) - def route(dst): - raise - - from model.utils import request - monkeypatch.setattr(request, 'HTTPError', MockHTTPError) - import logging - - class MockAPP: - logger = logging.getLogger('mock_app') - - mock_current_app = MockAPP() - monkeypatch.setattr(request, 'current_app', mock_current_app) - message, status_code = route(src=87) - assert status_code == 400 - assert message == 'Invalid parameter' diff --git a/tests/unittest/submission/test_check_code.py b/tests/unittest/submission/test_check_code.py index 802fcf73..aadcedfe 100644 --- a/tests/unittest/submission/test_check_code.py +++ b/tests/unittest/submission/test_check_code.py @@ -18,55 +18,50 @@ def teardown_function(_): class TestSubmissionCheckCode: def test_without_file(self, app): - with app.app_context(): - user = utils.user.create_user() - problem = utils.problem.create_problem() - submission = utils.submission.create_submission( - user=user, - problem=problem, - ) - result = submission._check_code(None) - assert result == 'no file' + user = utils.user.create_user() + problem = utils.problem.create_problem() + submission = utils.submission.create_submission( + user=user, + problem=problem, + ) + result = submission._check_code(None) + assert result == 'no file' def test_with_non_zip_file(self, app): - with app.app_context(): - user = utils.user.create_user() - problem = utils.problem.create_problem() - submission = utils.submission.create_submission( - user=user, - problem=problem, - ) - result = submission._check_code( - io.BytesIO(b'this is not a zip file')) - assert result == 'not a valid zip file' + user = utils.user.create_user() + problem = utils.problem.create_problem() + submission = utils.submission.create_submission( + user=user, + problem=problem, + ) + result = submission._check_code(io.BytesIO(b'this is not a zip file')) + assert result == 'not a valid zip file' def test_with_multiple_file_in_zip(self, app): - with app.app_context(): - user = utils.user.create_user() - problem = utils.problem.create_problem() - submission = utils.submission.create_submission( - user=user, - problem=problem, - ) - code = io.BytesIO() - with ZipFile(code, 'x') as zf: - zf.writestr('main.c', '#include \n') - zf.writestr('main.py', 'import os\n') - code = code.getvalue() - result = submission._check_code(io.BytesIO(code)) - assert result == 'more than one file in zip' + user = utils.user.create_user() + problem = utils.problem.create_problem() + submission = utils.submission.create_submission( + user=user, + problem=problem, + ) + code = io.BytesIO() + with ZipFile(code, 'x') as zf: + zf.writestr('main.c', '#include \n') + zf.writestr('main.py', 'import os\n') + code = code.getvalue() + result = submission._check_code(io.BytesIO(code)) + assert result == 'more than one file in zip' def test_with_filename_is_not_main(self, app): - with app.app_context(): - user = utils.user.create_user() - problem = utils.problem.create_problem() - submission = utils.submission.create_submission( - user=user, - problem=problem, - ) - code = io.BytesIO() - with ZipFile(code, 'x') as zf: - zf.writestr('m4in.c', '#include \n') - code = code.getvalue() - result = submission._check_code(io.BytesIO(code)) - assert result == 'only accept file with name \'main\'' + user = utils.user.create_user() + problem = utils.problem.create_problem() + submission = utils.submission.create_submission( + user=user, + problem=problem, + ) + code = io.BytesIO() + with ZipFile(code, 'x') as zf: + zf.writestr('m4in.c', '#include \n') + code = code.getvalue() + result = submission._check_code(io.BytesIO(code)) + assert result == 'only accept file with name \'main\'' diff --git a/tests/unittest/submission/test_get_output.py b/tests/unittest/submission/test_get_output.py index 64347ce3..aa6dc945 100644 --- a/tests/unittest/submission/test_get_output.py +++ b/tests/unittest/submission/test_get_output.py @@ -18,54 +18,51 @@ def teardown_function(_): class TestSubmissionGetOutput: def test_get_output_successfully(self, app): - with app.app_context(): - user = utils.user.create_user() - problem = utils.problem.create_problem( - test_case_info=utils.problem.create_test_case_info( - language=0, - task_len=1, - )) - submission = utils.submission.create_submission( - user=user, - problem=problem, - # AC - status=0, - ) - utils.submission.add_fake_output(submission) - output = submission.get_single_output(0, 0) - assert output == { - 'stdout': 'out', - 'stderr': 'err', - } + user = utils.user.create_user() + problem = utils.problem.create_problem( + test_case_info=utils.problem.create_test_case_info( + language=0, + task_len=1, + )) + submission = utils.submission.create_submission( + user=user, + problem=problem, + # AC + status=0, + ) + utils.submission.add_fake_output(submission) + output = submission.get_single_output(0, 0) + assert output == { + 'stdout': 'out', + 'stderr': 'err', + } def test_out_of_index(self, app): - with app.app_context(): - user = utils.user.create_user() - problem = utils.problem.create_problem() - submission = utils.submission.create_submission( - user=user, - problem=problem, - ) - with pytest.raises(FileNotFoundError) as err: - submission.get_single_output(100, 100) - assert str(err.value) == 'task not exist' + user = utils.user.create_user() + problem = utils.problem.create_problem() + submission = utils.submission.create_submission( + user=user, + problem=problem, + ) + with pytest.raises(FileNotFoundError) as err: + submission.get_single_output(100, 100) + assert str(err.value) == 'task not exist' def test_read_from_pending_submision(self, app): - with app.app_context(): - user = utils.user.create_user() - problem = utils.problem.create_problem( - test_case_info=utils.problem.create_test_case_info( - language=0, - task_len=1, - )) - submission = utils.submission.create_submission( - user=user, - problem=problem, - # AC - status=0, - ) - utils.submission.add_fake_output(submission) - submission.rejudge() - with pytest.raises(AttributeError) as err: - submission.get_single_output(0, 0) - assert str(err.value) == 'The submission is still in pending' + user = utils.user.create_user() + problem = utils.problem.create_problem( + test_case_info=utils.problem.create_test_case_info( + language=0, + task_len=1, + )) + submission = utils.submission.create_submission( + user=user, + problem=problem, + # AC + status=0, + ) + utils.submission.add_fake_output(submission) + submission.rejudge() + with pytest.raises(AttributeError) as err: + submission.get_single_output(0, 0) + assert str(err.value) == 'The submission is still in pending' diff --git a/tests/unittest/submission/test_penalty.py b/tests/unittest/submission/test_penalty.py index 8ed1dba5..b25ee10c 100644 --- a/tests/unittest/submission/test_penalty.py +++ b/tests/unittest/submission/test_penalty.py @@ -63,14 +63,13 @@ def test_penalty(client, app): markdown='', scoreboard_status=0, ) - with app.app_context(): - submission = utils.submission.create_submission( - problem=problem, - user=student, - lang=0, - score=100, - ) - submission.finish_judging() + submission = utils.submission.create_submission( + problem=problem, + user=student, + lang=0, + score=100, + ) + submission.finish_judging() assert Homework.get_by_name('Test', 'test').student_status[student.username][str( problem.id)]['score'] == 80 @@ -97,22 +96,21 @@ def test_penalty2(client, app): markdown='', scoreboard_status=0, ) - with app.app_context(): - submission = utils.submission.create_submission( - problem=problem, - user=student, - lang=0, - score=50, - timestamp=float(int(datetime.now().timestamp()) - 86410), - ) - submission.finish_judging() - submission = utils.submission.create_submission( - problem=problem, - user=student, - lang=0, - score=100, - ) - submission.finish_judging() + submission = utils.submission.create_submission( + problem=problem, + user=student, + lang=0, + score=50, + timestamp=float(int(datetime.now().timestamp()) - 86410), + ) + submission.finish_judging() + submission = utils.submission.create_submission( + problem=problem, + user=student, + lang=0, + score=100, + ) + submission.finish_judging() assert Homework.get_by_name( 'Test', 'test').student_status[student.username][str( problem.id)]['score'] == 85 and Homework.get_by_name( @@ -141,14 +139,13 @@ def test_no_penalty(client, app): markdown='', scoreboard_status=0, ) - with app.app_context(): - submission = utils.submission.create_submission( - problem=problem, - user=student, - lang=0, - score=100, - ) - submission.finish_judging() + submission = utils.submission.create_submission( + problem=problem, + user=student, + lang=0, + score=100, + ) + submission.finish_judging() assert Homework.get_by_name('Test', 'test').student_status[student.username][str( problem.id)]['score'] == 0 @@ -175,14 +172,13 @@ def test_penalty_in_time(client, app): markdown='', scoreboard_status=0, ) - with app.app_context(): - submission = utils.submission.create_submission( - problem=problem, - user=student, - lang=0, - score=100, - ) - submission.finish_judging() + submission = utils.submission.create_submission( + problem=problem, + user=student, + lang=0, + score=100, + ) + submission.finish_judging() assert Homework.get_by_name('Test', 'test').student_status[student.username][str( problem.id)]['score'] == 100 diff --git a/tests/unittest/submission/test_target_sandbox.py b/tests/unittest/submission/test_target_sandbox.py new file mode 100644 index 00000000..4e0c32dc --- /dev/null +++ b/tests/unittest/submission/test_target_sandbox.py @@ -0,0 +1,84 @@ +import os +import pytest +import httpx +from mongo import Submission +from mongo import engine +from tests import utils + + +def setup_function(_): + utils.drop_db() + Submission._config = None + os.environ['TESTING'] = '1' + + +def teardown_function(_): + utils.drop_db() + Submission._config = None + os.environ.pop('TESTING', None) + + +def _make_client(handler): + return httpx.Client(transport=httpx.MockTransport(handler)) + + +@pytest.fixture +def two_sandboxes(): + Submission.config().update(sandbox_instances=[ + engine.Sandbox(name='sb1', url='http://sb1:6666', token='tok1'), + engine.Sandbox(name='sb2', url='http://sb2:6666', token='tok2'), + ]) + + +def _make_submission(): + admin = utils.user.create_user(role=0) + problem_id = utils.problem.create_problem( + owner=admin, + course='Public', + ).problem_id + return utils.submission.create_submission( + user=admin, + problem=problem_id, + ) + + +def test_target_sandbox_skips_unhealthy(two_sandboxes): + submission = _make_submission() + + def handler(request): + if 'sb1' in str(request.url): + return httpx.Response(503, text='unavailable') + return httpx.Response(200, json={'load': 5}) + + with _make_client(handler) as client: + target = submission.target_sandbox(client=client) + assert target is not None + assert target.name == 'sb2' + + +def test_target_sandbox_picks_lowest_load(two_sandboxes): + submission = _make_submission() + + def handler(request): + if 'sb1' in str(request.url): + return httpx.Response(200, json={'load': 10}) + return httpx.Response(200, json={'load': 2}) + + with _make_client(handler) as client: + target = submission.target_sandbox(client=client) + assert target is not None + assert target.name == 'sb2' + + +def test_target_sandbox_returns_none_when_all_unhealthy(): + submission = _make_submission() + Submission.config().update(sandbox_instances=[ + engine.Sandbox(name='sb1', url='http://sb1:6666', token='tok1'), + ]) + + def handler(request): + return httpx.Response(503, text='down') + + with _make_client(handler) as client: + target = submission.target_sandbox(client=client) + assert target is None