Functions added for wiping tickets when expired (the entire ticket cycle). Also general error fixing for the payments flow
This commit is contained in:
parent
840c2ccb58
commit
a7b733db0d
8 changed files with 205 additions and 67 deletions
|
|
@ -65,24 +65,29 @@ def translate_error(error: Type[Exception], profile_id: int) -> Result:
|
||||||
return Result(valid=False, error_type=ResultError.MISSING_SOFTWARE, message=error_msg)
|
return Result(valid=False, error_type=ResultError.MISSING_SOFTWARE, message=error_msg)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# Extract the traceback from the exception object
|
error_msg = get_traceback_line(error)
|
||||||
tb = error.__traceback__
|
|
||||||
|
|
||||||
# extract_tb returns a list of frames; the LAST one [-1] is where the error happened
|
|
||||||
summary = traceback.extract_tb(tb)
|
|
||||||
if summary:
|
|
||||||
last_frame = summary[-1]
|
|
||||||
func_name = last_frame.name # The function name
|
|
||||||
line_no = last_frame.lineno # The line number
|
|
||||||
file_name = last_frame.filename # The file path
|
|
||||||
else:
|
|
||||||
func_name, line_no, file_name = "Unknown", "Unknown", "Unknown"
|
|
||||||
|
|
||||||
error_msg = f"Error in {func_name} at {file_name}:{line_no} -> {str(error)}"
|
|
||||||
logger.error(error_msg)
|
|
||||||
return Result(valid=False, error_type=ResultError.UNKNOWN, message=error_msg)
|
return Result(valid=False, error_type=ResultError.UNKNOWN, message=error_msg)
|
||||||
|
|
||||||
|
|
||||||
|
def get_traceback_line(error) -> str:
|
||||||
|
# Extract the traceback from the exception object
|
||||||
|
tb = error.__traceback__
|
||||||
|
|
||||||
|
# extract_tb returns a list of frames; the LAST one [-1] is where the error happened
|
||||||
|
summary = traceback.extract_tb(tb)
|
||||||
|
if summary:
|
||||||
|
last_frame = summary[-1]
|
||||||
|
func_name = last_frame.name # The function name
|
||||||
|
line_no = last_frame.lineno # The line number
|
||||||
|
file_name = last_frame.filename # The file path
|
||||||
|
else:
|
||||||
|
func_name, line_no, file_name = "Unknown", "Unknown", "Unknown"
|
||||||
|
|
||||||
|
error_msg = f"Error in {func_name} at {file_name}:{line_no} -> {str(error)}"
|
||||||
|
logger.error(error_msg)
|
||||||
|
return error_msg
|
||||||
|
|
||||||
|
|
||||||
@type_checker
|
@type_checker
|
||||||
def get_ignored_exceptions(ignore_state_conflict: Optional[bool] = False):
|
def get_ignored_exceptions(ignore_state_conflict: Optional[bool] = False):
|
||||||
ignored_as_list = []
|
ignored_as_list = []
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
from core.infrastructure.thread_dispatch import MainThreadDispatcher
|
from core.infrastructure.thread_dispatch import MainThreadDispatcher
|
||||||
from core.infrastructure.error_popup import PopupProtocol
|
from core.infrastructure.error_popup import PopupProtocol
|
||||||
from core.infrastructure.service_registry import ServiceLocator
|
from core.infrastructure.service_registry import ServiceLocator
|
||||||
|
from core.errors.translate_error import get_traceback_line
|
||||||
from core.models.Result import Result, ResultError
|
from core.models.Result import Result, ResultError
|
||||||
# from core.infrastructure.action_registry import ENUM_MAP
|
# from core.infrastructure.action_registry import ENUM_MAP
|
||||||
from core.errors.logger import logger
|
from core.errors.logger import logger
|
||||||
|
|
@ -66,8 +66,9 @@ class ThreadPlumbing:
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_msg = f"There was an error: {str(e)}"
|
error_msg = f"There was an error: {str(e)}"
|
||||||
# notify(error_msg)
|
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
|
stack_trace = get_traceback_line(e)
|
||||||
|
logger.error(stack_trace)
|
||||||
result = Result(valid=False, error_type=ResultError.UNKNOWN, message=str(e))
|
result = Result(valid=False, error_type=ResultError.UNKNOWN, message=str(e))
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
|
|
@ -117,7 +118,7 @@ class ThreadPlumbing:
|
||||||
# otherwise return it as is:
|
# otherwise return it as is:
|
||||||
else:
|
else:
|
||||||
if self.ui_caller:
|
if self.ui_caller:
|
||||||
self.ui_caller(passed_result)
|
self.ui_caller(result)
|
||||||
|
|
||||||
|
|
||||||
def _on_popup_answer(self, user_choice: bool, passed_result: Result):
|
def _on_popup_answer(self, user_choice: bool, passed_result: Result):
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ class ResultError(Enum):
|
||||||
DATABASE = "database"
|
DATABASE = "database"
|
||||||
PERMISSION = "permission"
|
PERMISSION = "permission"
|
||||||
SUBSCRIPTION = "subscription"
|
SUBSCRIPTION = "subscription"
|
||||||
EXPIRED = "expired" # meant for invoice
|
EXPIRED_INVOICE = "expired_invoice" # meant for invoice
|
||||||
UNPAID = "unpaid"
|
UNPAID = "unpaid"
|
||||||
EXPIRED_SUB = "expired_sub"
|
EXPIRED_SUB = "expired_sub"
|
||||||
ACTIVE_BILLING = "active_billing"
|
ACTIVE_BILLING = "active_billing"
|
||||||
|
|
@ -29,6 +29,7 @@ class ResultError(Enum):
|
||||||
NO_TICKET = "no_ticket"
|
NO_TICKET = "no_ticket"
|
||||||
RANDOM_TICKETS_OFF = "random_tickets_off"
|
RANDOM_TICKETS_OFF = "random_tickets_off"
|
||||||
TICKET_SETUP = "ticket_setup"
|
TICKET_SETUP = "ticket_setup"
|
||||||
|
TICKET_EXPIRED = "ticket_expired"
|
||||||
ALREADY_EXISTS = "already_exists"
|
ALREADY_EXISTS = "already_exists"
|
||||||
BILLING_CODE_EXISTS = "billing_code_exists"
|
BILLING_CODE_EXISTS = "billing_code_exists"
|
||||||
PROCESS_GOT_KILLED = "process_got_killed"
|
PROCESS_GOT_KILLED = "process_got_killed"
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ TRANSLATION_DICT = {
|
||||||
ServerError.MISSING_DATA: ResultError.INVALID_INPUT,
|
ServerError.MISSING_DATA: ResultError.INVALID_INPUT,
|
||||||
ServerError.UNPAID: ResultError.SUBSCRIPTION,
|
ServerError.UNPAID: ResultError.SUBSCRIPTION,
|
||||||
ServerError.SERVER_SIDE: ResultError.SERVER_SIDE,
|
ServerError.SERVER_SIDE: ResultError.SERVER_SIDE,
|
||||||
ServerError.EXPIRED: ResultError.SUBSCRIPTION
|
ServerError.EXPIRED: ResultError.EXPIRED_INVOICE
|
||||||
}
|
}
|
||||||
|
|
||||||
def format_server_response(api_response: ApiResponse|dict, desired_type: str) -> Result:
|
def format_server_response(api_response: ApiResponse|dict, desired_type: str) -> Result:
|
||||||
|
|
|
||||||
|
|
@ -130,14 +130,14 @@ class StatePersistenceMixin(ValidatedDataclass):
|
||||||
setattr(self, field.name, value)
|
setattr(self, field.name, value)
|
||||||
|
|
||||||
|
|
||||||
def wipe_data_on_disk(self):
|
def wipe_data_on_disk(self):
|
||||||
logger.info("Starting wipe of pre-existing data")
|
logger.info("Starting wipe of pre-existing data")
|
||||||
|
|
||||||
if not os.path.exists(self._data_file):
|
if not os.path.exists(self._data_file):
|
||||||
logger.info("File does NOT exist already")
|
logger.info("File does NOT exist already")
|
||||||
return
|
return
|
||||||
|
|
||||||
return delete_file(self._data_file)
|
return delete_file(self._data_file)
|
||||||
|
|
||||||
|
|
||||||
def exists_already(self) -> bool:
|
def exists_already(self) -> bool:
|
||||||
|
|
@ -215,6 +215,16 @@ class PaymentData(StatePersistenceMixin):
|
||||||
|
|
||||||
self.load()
|
self.load()
|
||||||
|
|
||||||
|
def wipe(self):
|
||||||
|
"""Wipe all data and start over."""
|
||||||
|
wiped = self.wipe_data_on_disk()
|
||||||
|
self.currency = None
|
||||||
|
self.payment_type = None
|
||||||
|
self.plan_id = None
|
||||||
|
self.location_id = None
|
||||||
|
self.protocol_choice = None
|
||||||
|
|
||||||
|
|
||||||
@singleton
|
@singleton
|
||||||
@dataclass
|
@dataclass
|
||||||
class OverallPaymentCycle():
|
class OverallPaymentCycle():
|
||||||
|
|
|
||||||
42
core/services/payment_phase/ticket_cycle.py
Normal file
42
core/services/payment_phase/ticket_cycle.py
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
from core.services.subscriptions.subscriptions_by_protocol import wipe_billing_cycle
|
||||||
|
from core.services.payment_phase.payment_state import OverallPaymentCycle, TicketPaymentData
|
||||||
|
from core.controllers.ConfigurationController import ConfigurationController
|
||||||
|
from core.services.prepare_tickets.ticket_tracker import ticket_tracker_exists, delete_ticket_data
|
||||||
|
from core.utils.basic_operations.file_tools import delete_file
|
||||||
|
from core.Constants import Constants
|
||||||
|
from core.models.Result import Result, ResultError
|
||||||
|
from core.errors.logger import logger
|
||||||
|
|
||||||
|
def wipe_ticket_cycle() -> Result:
|
||||||
|
"""Wipe the entire global ticketing cycle & all associated tickets."""
|
||||||
|
|
||||||
|
# global temp billing in memory:
|
||||||
|
ticket_config = TicketPaymentData()
|
||||||
|
ticket_config.wipe()
|
||||||
|
|
||||||
|
# wipe both file and in memory cycle:
|
||||||
|
wiped_whole_cycle = wipe_billing_cycle()
|
||||||
|
|
||||||
|
# wipe tickets tracker:
|
||||||
|
if ticket_tracker_exists():
|
||||||
|
deleted_tracker = delete_file(Constants.TICKET_TRACKER_PATH)
|
||||||
|
else:
|
||||||
|
deleted_tracker = True
|
||||||
|
|
||||||
|
# wipe tickets themselves:
|
||||||
|
wiped_literal_tickets = delete_ticket_data()
|
||||||
|
|
||||||
|
# wipe global "use" config:
|
||||||
|
config_wiped = ConfigurationController.tickets_setup(False)
|
||||||
|
random_stopped = ConfigurationController.change_random_ticket(False)
|
||||||
|
|
||||||
|
if (
|
||||||
|
wiped_whole_cycle.valid and
|
||||||
|
config_wiped and
|
||||||
|
random_stopped and
|
||||||
|
deleted_tracker and
|
||||||
|
wiped_literal_tickets
|
||||||
|
):
|
||||||
|
return Result(valid=True)
|
||||||
|
else:
|
||||||
|
return Result(valid=False, error_type=ResultError.FILE_SYSTEM, message="Unable to wipe ticket billing cycle.")
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
from core.services.payment_phase.check_payment_loop import CheckPaidTimer
|
from core.services.payment_phase.check_payment_loop import CheckPaidTimer
|
||||||
from core.models.manage.server_reply_tools import api_call_and_extraction, ApiCall
|
from core.models.manage.server_reply_tools import api_call_and_extraction, ApiCall
|
||||||
from core.controllers.ProfileController import ProfileController
|
from core.controllers.ProfileController import ProfileController
|
||||||
|
from core.controllers.ConfigurationController import ConfigurationController
|
||||||
from core.models.BaseProfile import BaseProfile as Profile
|
from core.models.BaseProfile import BaseProfile as Profile
|
||||||
from core.services.payment_phase.payment_state import OverallPaymentCycle, PaymentData, TicketPaymentData
|
from core.services.payment_phase.payment_state import OverallPaymentCycle, PaymentData, TicketPaymentData
|
||||||
from core.models.enum_models import ProtocolChoice, PaymentCategory
|
from core.models.enum_models import ProtocolChoice, PaymentCategory
|
||||||
|
|
@ -235,10 +236,30 @@ def return_pre_existing_invoice(billing_code: str, connection_observer: Connecti
|
||||||
url = f'{Constants.SP_BASE_URL}/v2/get_invoice'
|
url = f'{Constants.SP_BASE_URL}/v2/get_invoice'
|
||||||
|
|
||||||
result = _api_call_for_invoice(url=url, payload=payload, connection_observer=connection_observer)
|
result = _api_call_for_invoice(url=url, payload=payload, connection_observer=connection_observer)
|
||||||
|
|
||||||
|
#-----------------------------
|
||||||
|
# EXPIRED? CLARIFY ON WHAT'S EXPIRED (TICKETS VS INDIVIDUAL)
|
||||||
|
#-----------------------------
|
||||||
|
if not result.valid and result.error_type == ResultError.EXPIRED_INVOICE:
|
||||||
|
# TICKET
|
||||||
|
if category == PaymentCategory.TICKET:
|
||||||
|
result.error_type = ResultError.TICKET_EXPIRED
|
||||||
|
result.message = "This ticket billing is expired. Do you want to wipe the ticket config & literal tickets and start over?"
|
||||||
|
return result
|
||||||
|
|
||||||
|
# INDIVIDUAL
|
||||||
|
if category == PaymentCategory.INDIVIDUAL:
|
||||||
|
result.error_type = ResultError.EXPIRED_INVOICE
|
||||||
|
result.message = "This invoice is expired. Do you want to wipe it and start over?"
|
||||||
|
return result
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def get_ticket_invoice(connection_observer: ConnectionObserver) -> Result:
|
def get_ticket_invoice(connection_observer: ConnectionObserver) -> Result:
|
||||||
"""Evaluates if it needs a new ticket invoice, and then returns pre-existing"""
|
"""Evaluates if it needs a new ticket invoice, and then returns pre-existing"""
|
||||||
|
|
||||||
|
|
@ -429,7 +450,7 @@ def get_current_billing_data(category: Optional[PaymentCategory] = None) -> Resu
|
||||||
return Result(valid=True, data=billing_data)
|
return Result(valid=True, data=billing_data)
|
||||||
|
|
||||||
|
|
||||||
def wipe_billing_cycle() -> Result:
|
def wipe_billing_cycle(inserted_profile_id: Optional[int] = None) -> Result:
|
||||||
"""Wipes the entire overall cycle"""
|
"""Wipes the entire overall cycle"""
|
||||||
logger.info("Wiping the overall cycle, so this cycle no longer blocks other new ones")
|
logger.info("Wiping the overall cycle, so this cycle no longer blocks other new ones")
|
||||||
|
|
||||||
|
|
@ -446,10 +467,38 @@ def wipe_billing_cycle() -> Result:
|
||||||
|
|
||||||
# Overall State:
|
# Overall State:
|
||||||
payment_cycle = OverallPaymentCycle()
|
payment_cycle = OverallPaymentCycle()
|
||||||
if payment_cycle:
|
if not payment_cycle:
|
||||||
payment_cycle.wipe()
|
return Result(valid=True)
|
||||||
|
|
||||||
# This has to return a Result object, even if not checked, because of it's use in plumbing,
|
# if no profile id was given, wipe it and move on:
|
||||||
|
if not inserted_profile_id:
|
||||||
|
payment_cycle.wipe()
|
||||||
|
return Result(valid=True)
|
||||||
|
|
||||||
|
# from here on assume we were given a profile id
|
||||||
|
return delete_individual_cycle(payment_cycle=payment_cycle, inserted_profile_id=inserted_profile_id)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_individual_cycle(payment_cycle: OverallPaymentCycle, inserted_profile_id: int) -> Result:
|
||||||
|
|
||||||
|
logger.info("Deleting an individual profile")
|
||||||
|
billing_data = payment_cycle.billing_data
|
||||||
|
if not billing_data:
|
||||||
|
error_msg = "No billing data to wipe. Did you already wipe it?"
|
||||||
|
logger.error(error_msg)
|
||||||
|
return Result(valid=False, error_type=ResultError.MISSING_DATA, message=error_msg)
|
||||||
|
|
||||||
|
cycle_profile_id = billing_data.instance_id
|
||||||
|
|
||||||
|
if inserted_profile_id != cycle_profile_id:
|
||||||
|
error_msg = f"You're trying to wipe the wrong profile. You hit delete {inserted_profile_id} but the overall cycle is for {cycle_profile_id}"
|
||||||
|
logger.error(error_msg)
|
||||||
|
return Result(valid=False, error_type=ResultError.INVALID_INPUT, message=error_msg)
|
||||||
|
|
||||||
|
payment_cycle.wipe()
|
||||||
|
|
||||||
|
individual_cycle = PaymentData(instance_id=inserted_profile_id)
|
||||||
|
individual_cycle.wipe()
|
||||||
return Result(valid=True)
|
return Result(valid=True)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -572,6 +621,6 @@ def return_pre_existing_payment_data(profile_id: int, subscription: Subscription
|
||||||
return Result(valid=False, message=error_msg, error_type=ResultError.INVALID_INPUT)
|
return Result(valid=False, message=error_msg, error_type=ResultError.INVALID_INPUT)
|
||||||
|
|
||||||
|
|
||||||
def evaluate_ticketing():
|
def evaluate_ticketing() -> bool:
|
||||||
setup = ConfigurationController.are_tickets_setup()
|
setup = ConfigurationController.are_tickets_setup()
|
||||||
|
return setup
|
||||||
|
|
@ -1,57 +1,49 @@
|
||||||
from core.models.Result import Result, ResultError
|
from core.models.Result import Result, ResultError
|
||||||
from core.errors.logger import logger
|
from core.errors.logger import logger
|
||||||
|
|
||||||
|
|
||||||
import inspect
|
import inspect
|
||||||
from functools import wraps
|
from functools import wraps, partial
|
||||||
from typing import get_origin, get_args, Union
|
from typing import get_origin, get_args, Union, Any, Literal, Callable, Annotated
|
||||||
|
from collections.abc import Callable as ABCCallable
|
||||||
import types # Required for UnionType (the | operator)
|
import types # Required for UnionType (the | operator)
|
||||||
|
|
||||||
def _get_type_error_details(check_type, value, origin, args_types):
|
def _get_type_error_details(check_type, value, origin, args_types):
|
||||||
"""
|
|
||||||
Helper to construct human-readable names for the expected and actual types.
|
|
||||||
"""
|
|
||||||
# Case 1: User used Type[T] but passed an instance of T
|
|
||||||
if origin is type:
|
if origin is type:
|
||||||
inner_name = getattr(args_types[0], '__name__', 'the specified type')
|
inner_name = getattr(args_types[0], '__name__', 'the specified type')
|
||||||
return f"a class/type (subclass of {inner_name})", f"an instance of {type(value).__name__}"
|
return f"a class/type (subclass of {inner_name})", f"an instance of {type(value).__name__}"
|
||||||
|
|
||||||
# Case 2: check_type is a tuple (common for Unions)
|
|
||||||
if isinstance(check_type, tuple):
|
if isinstance(check_type, tuple):
|
||||||
type_names = ", ".join([getattr(t, '__name__', str(t)) for t in check_type])
|
type_names = ", ".join([getattr(t, '__name__', str(t)) for t in check_type])
|
||||||
return f"one of ({type_names})", f"type {type(value).__name__}"
|
return f"one of ({type_names})", f"type {type(value).__name__}"
|
||||||
|
|
||||||
# Case 3: Standard single type
|
|
||||||
type_name = getattr(check_type, '__name__', str(check_type))
|
type_name = getattr(check_type, '__name__', str(check_type))
|
||||||
return type_name, f"type {type(value).__name__}"
|
return type_name, f"type {type(value).__name__}"
|
||||||
|
|
||||||
def _validate_inner_types(value, origin, args_types, func, name):
|
def _validate_inner_types(value, origin, args_types, func, name):
|
||||||
"""
|
|
||||||
Helper to handle deep validation for generics like List[int] or Type[T].
|
|
||||||
Returns (is_valid, error_message).
|
|
||||||
"""
|
|
||||||
if not args_types:
|
if not args_types:
|
||||||
return True, None
|
return True, None
|
||||||
|
|
||||||
inner_type = args_types[0]
|
inner_type = args_types[0]
|
||||||
|
if inner_type is Any:
|
||||||
# Handle Type[T]: value must be a subclass of T
|
return True, None
|
||||||
if origin is type:
|
|
||||||
if not issubclass(value, inner_type):
|
if origin is type:
|
||||||
inner_name = getattr(inner_type, '__name__', str(inner_type))
|
try:
|
||||||
return False, f"For function {func}, Argument '{name}' must be a subclass of {inner_name}, got {value.__name__}"
|
if not issubclass(value, inner_type):
|
||||||
|
inner_name = getattr(inner_type, '__name__', str(inner_type))
|
||||||
|
return False, f"For function {func}, Argument '{name}' must be a subclass of {inner_name}, got {value.__name__}"
|
||||||
|
except TypeError:
|
||||||
|
return False, f"For function {func}, Argument '{name}' expected a class, got {type(value).__name__}"
|
||||||
return True, None
|
return True, None
|
||||||
|
|
||||||
# Handle Collections: list, set, tuple
|
|
||||||
if isinstance(value, (list, set, tuple)):
|
if isinstance(value, (list, set, tuple)):
|
||||||
for item in value:
|
for item in value:
|
||||||
|
if inner_type is Any:
|
||||||
|
continue
|
||||||
inner_origin = get_origin(inner_type)
|
inner_origin = get_origin(inner_type)
|
||||||
|
|
||||||
# Handle Unions inside collections (e.g., List[Union[int, str]])
|
|
||||||
if inner_origin is Union or (hasattr(types, "UnionType") and inner_origin is types.UnionType):
|
if inner_origin is Union or (hasattr(types, "UnionType") and inner_origin is types.UnionType):
|
||||||
if not isinstance(item, get_args(inner_type)):
|
if not isinstance(item, get_args(inner_type)):
|
||||||
return False, f"For function {func}, Argument '{name}' contains element {type(item).__name__}, expected {inner_type}"
|
return False, f"For function {func}, Argument '{name}' contains element {type(item).__name__}, expected {inner_type}"
|
||||||
# Handle standard types inside collections
|
|
||||||
elif not isinstance(item, inner_type):
|
elif not isinstance(item, inner_type):
|
||||||
inner_name = getattr(inner_type, '__name__', str(inner_type))
|
inner_name = getattr(inner_type, '__name__', str(inner_type))
|
||||||
return False, f"For function {func}, Argument '{name}' contains element {type(item).__name__}, expected {inner_name}"
|
return False, f"For function {func}, Argument '{name}' contains element {type(item).__name__}, expected {inner_name}"
|
||||||
|
|
@ -63,7 +55,6 @@ def type_checker(func):
|
||||||
def wrapper(*args, **kwargs):
|
def wrapper(*args, **kwargs):
|
||||||
sig = inspect.signature(func)
|
sig = inspect.signature(func)
|
||||||
|
|
||||||
# 1. Bind arguments to names to handle both positional and keyword args
|
|
||||||
try:
|
try:
|
||||||
bound_args = sig.bind(*args, **kwargs)
|
bound_args = sig.bind(*args, **kwargs)
|
||||||
bound_args.apply_defaults()
|
bound_args.apply_defaults()
|
||||||
|
|
@ -72,31 +63,70 @@ def type_checker(func):
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
return Result(valid=False, error_type=ResultError.INVALID_INPUT, message=error_msg)
|
return Result(valid=False, error_type=ResultError.INVALID_INPUT, message=error_msg)
|
||||||
|
|
||||||
# 2. Validate each argument against its type hint
|
|
||||||
for name, value in bound_args.arguments.items():
|
for name, value in bound_args.arguments.items():
|
||||||
expected_type = sig.parameters[name].annotation
|
expected_type = sig.parameters[name].annotation
|
||||||
|
|
||||||
# Skip if no type hint is provided
|
|
||||||
if expected_type is inspect.Parameter.empty:
|
if expected_type is inspect.Parameter.empty:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# --- PHASE 1: SANITIZATION ---
|
||||||
|
# 1. Handle Any (always valid)
|
||||||
|
if expected_type is Any:
|
||||||
|
continue
|
||||||
|
|
||||||
origin = get_origin(expected_type)
|
origin = get_origin(expected_type)
|
||||||
args_types = get_args(expected_type)
|
args_types = get_args(expected_type)
|
||||||
|
|
||||||
# Determine the type to use for isinstance()
|
# 2. Handle Annotated[T, ...] (peel the wrapper to get T)
|
||||||
# Handles both typing.Union and the Python 3.10+ | operator (UnionType)
|
if origin is Annotated:
|
||||||
is_union = origin is Union or (hasattr(types, "UnionType") and origin is types.UnionType)
|
expected_type = args_types[0]
|
||||||
check_type = args_types if is_union else (origin if origin is not None else expected_type)
|
origin = get_origin(expected_type)
|
||||||
|
args_types = get_args(expected_type)
|
||||||
|
|
||||||
# Basic type validation
|
# 3. Handle Literal (check if value is one of the allowed literals)
|
||||||
if not isinstance(value, check_type):
|
if origin is Literal:
|
||||||
|
if value not in args_types:
|
||||||
|
error_msg = f"For function {func}, Argument '{name}' must be one of {args_types}, got {value}"
|
||||||
|
logger.error(error_msg)
|
||||||
|
return Result(valid=False, error_type=ResultError.INVALID_INPUT, message=error_msg)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# --- PHASE 2: TYPE DETERMINATION ---
|
||||||
|
is_union = origin is Union or (hasattr(types, "UnionType") and origin is types.UnionType)
|
||||||
|
is_callable_hint = (expected_type is Callable or expected_type is ABCCallable or origin is ABCCallable)
|
||||||
|
|
||||||
|
if is_union:
|
||||||
|
# Sanitize Union tuple: replace typing.Callable with ABCCallable
|
||||||
|
check_type = tuple(ABCCallable if t is Callable else t for t in args_types)
|
||||||
|
else:
|
||||||
|
check_type = origin if origin is not None else expected_type
|
||||||
|
|
||||||
|
# --- PHASE 3: VALIDATION ---
|
||||||
|
is_valid = True
|
||||||
|
|
||||||
|
if is_callable_hint and not is_union:
|
||||||
|
# Use callable() for partials, lambdas, and functions
|
||||||
|
if not callable(value):
|
||||||
|
is_valid = False
|
||||||
|
elif not is_union and check_type is Callable:
|
||||||
|
is_valid = callable(value)
|
||||||
|
else:
|
||||||
|
# FINAL SAFETY CHECK:
|
||||||
|
# Only call isinstance if check_type is actually a type or a tuple of types.
|
||||||
|
# This prevents the "arg 2 must be a type" crash for any remaining SpecialForms.
|
||||||
|
if isinstance(check_type, (type, tuple)):
|
||||||
|
if not isinstance(value, check_type):
|
||||||
|
is_valid = False
|
||||||
|
else:
|
||||||
|
# If we reached here and it's not a type/tuple, it's a SpecialForm we can't check.
|
||||||
|
# We treat it as valid to avoid crashing.
|
||||||
|
is_valid = True
|
||||||
|
|
||||||
|
if not is_valid:
|
||||||
type_name, actual_name = _get_type_error_details(check_type, value, origin, args_types)
|
type_name, actual_name = _get_type_error_details(check_type, value, origin, args_types)
|
||||||
error_msg = f"For function {func}, Argument '{name}' must be {type_name}, got {actual_name}"
|
error_msg = f"For function {func}, Argument '{name}' must be {type_name}, got {actual_name}"
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
return Result(valid=False, error_type=ResultError.INVALID_INPUT, message=error_msg)
|
return Result(valid=False, error_type=ResultError.INVALID_INPUT, message=error_msg)
|
||||||
|
|
||||||
# Deep validation for generics (e.g., List[int], Type[MyClass])
|
|
||||||
# We only do this if it's not a Union, as Unions were handled by the basic isinstance check above
|
|
||||||
if origin is not None and not is_union:
|
if origin is not None and not is_union:
|
||||||
is_valid, error_msg = _validate_inner_types(value, origin, args_types, func, name)
|
is_valid, error_msg = _validate_inner_types(value, origin, args_types, func, name)
|
||||||
if not is_valid:
|
if not is_valid:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue