diff --git a/matemat/webserver/requestargs.py b/matemat/webserver/requestargs.py index a35f759..02df0b2 100644 --- a/matemat/webserver/requestargs.py +++ b/matemat/webserver/requestargs.py @@ -1,121 +1,273 @@ -from typing import List, Optional, Tuple, Union +from typing import Iterator, List, Optional, Tuple, Union class RequestArgument(object): + """ + Container class for HTTP request arguments that simplifies dealing with + - scalar and array arguments: + Automatically converts between single values and arrays where necessary: Arrays with one element can be + accessed as scalars, and scalars can be iterated, yielding themselves as a single item. + - UTF-8 strings and binary data (e.g. file uploads): + All data can be retrieved both as a str (if utf-8 decoding is possible) and a bytes object. + + The objects returned from iteration or indexing are immutable views of (parts of) this object. + + Usage example: + + qsargs = urllib.parse.parse_qs(qs, strict_parsing=True, keep_blank_values=True, errors='strict') + args: Dict[str, RequestArgument] = dict() + for k, vs in qsargs: + args[k] = RequestArgument(k) + for v in vs: + # text/plain usually is a sensible choice for values decoded from urlencoded strings + # IF ALREADY IN STRING FORM (which parse_qs does)! + args[k].append('text/plain', v) + + if 'username' in args and args['username'].is_scalar: + username: str = args['username'].get_str() + else: + raise ValueError() + + """ def __init__(self, name: str, value: Union[Tuple[str, Union[bytes, str]], List[Tuple[str, Union[bytes, str]]]] = None) -> None: + """ + Create a new RequestArgument with a name and optionally an initial value. + + :param name: The name for this argument, as provided via GET or POST. + :param value: The initial value, if any. Optional, initializes with empty array if omitted. + """ + # Assign name self.__name: str = name + # Initialize value self.__value: Union[Tuple[str, Union[bytes, str]], List[Tuple[str, Union[bytes, str]]]] = None + # Default to empty array if value is None: self.__value = [] else: if isinstance(value, list): if len(value) == 1: + # An array of length 1 will be reduced to a scalar self.__value = value[0] else: + # Store the array self.__value = value else: + # Scalar value, simply store self.__value = value @property def is_array(self) -> bool: + """ + :return: True, if the value is a (possibly empty) array, False otherwise. + """ return isinstance(self.__value, list) @property def is_scalar(self) -> bool: + """ + :return: True, if the value is a single scalar value, False otherwise. + """ return not isinstance(self.__value, list) @property def is_view(self) -> bool: + """ + :return: True, if this instance is an immutable view, False otherwise. + """ return False @property def name(self) -> str: + """ + :return: The name of this argument. + """ return self.__name - def get_str(self, index: int = None) -> Optional[str]: + def get_str(self, index: int = None) -> str: + """ + Attempts to return a value as a string. If this instance is an scalar, no index must be provided. If this + instance is an array, an index must be provided. + + :param index: For array values: The index of the value to retrieve. For scalar values: None (default). + :return: An UTF-8 string representation of the requested value. + :raises UnicodeDecodeError: If the value cannot be decoded into an UTF-8 string. + :raises IndexError: If the index is out of bounds. + :raises ValueError: If this is an array value, and no index is provided, or if this is a scalar value and an + index is provided. + """ if self.is_array: + # instance is an array value if index is None: + # Needs an index for array values raise ValueError('index must not be None') + # Type hint; access array element v: Tuple[str, Union[bytes, str]] = self.__value[index] if isinstance(v[1], str): + # The value already is a string, return return v[1] elif isinstance(v[1], bytes): + # The value is a bytes object, attempt to decode return v[1].decode('utf-8') else: + # instance is a scalar value if index is not None: + # Must not have an index for array values raise ValueError('index must be None') if isinstance(self.__value[1], str): + # The value already is a string, return return self.__value[1] elif isinstance(self.__value[1], bytes): + # The value is a bytes object, attempt to decode return self.__value[1].decode('utf-8') - def get_bytes(self, index: int = None) -> Optional[bytes]: + def get_bytes(self, index: int = None) -> bytes: + """ + Attempts to return a value as a bytes object. If this instance is an scalar, no index must be provided. If + this instance is an array, an index must be provided. + + :param index: For array values: The index of the value to retrieve. For scalar values: None (default). + :return: A bytes object representation of the requested value. Strings will be encoded as UTF-8. + :raises IndexError: If the index is out of bounds. + :raises ValueError: If this is an array value, and no index is provided, or if this is a scalar value and an + index is provided. + """ if self.is_array: + # instance is an array value if index is None: + # Needs an index for array values raise ValueError('index must not be None') + # Type hint; access array element v: Tuple[str, Union[bytes, str]] = self.__value[index] if isinstance(v[1], bytes): + # The value already is a bytes object, return return v[1] elif isinstance(v[1], str): + # The value is a string, encode first return v[1].encode('utf-8') else: + # instance is a scalar value if index is not None: + # Must not have an index for array values raise ValueError('index must be None') if isinstance(self.__value[1], bytes): + # The value already is a bytes object, return return self.__value[1] elif isinstance(self.__value[1], str): + # The value is a string, encode first return self.__value[1].encode('utf-8') def get_content_type(self, index: int = None) -> Optional[str]: + """ + Attempts to retrieve a value's Content-Type. If this instance is an scalar, no index must be provided. If this + instance is an array, an index must be provided. + + :param index: For array values: The index of the value to retrieve. For scalar values: None (default). + :return: The Content-Type of the requested value, as sent by the client. Not necessarily trustworthy. + :raises IndexError: If the index is out of bounds. + :raises ValueError: If this is an array value, and no index is provided, or if this is a scalar value and an + index is provided. + """ if self.is_array: + # instance is an array value if index is None: + # Needs an index for array values raise ValueError('index must not be None') + # Type hint; access array element v: Tuple[str, Union[bytes, str]] = self.__value[index] + # Return the content type of the requested value return v[0] else: + # instance is a scalar value if index is not None: + # Must not have an index for array values raise ValueError('index must be None') + # Return the content type of the scalar value return self.__value[0] def append(self, ctype: str, value: Union[str, bytes]): + """ + Append a value to this instance. Turns an empty argument into a scalar and a scalar into an array. + + :param ctype: The Content-Type, as provided in the request. + :param value: The scalar value to append, either a string or bytes object. + :raises TypeError: If called on an immutable view. + """ if self.is_view: + # This is an immutable view, raise exception raise TypeError('A RequestArgument view is immutable!') if len(self) == 0: + # Turn an empty argument into a scalar self.__value = ctype, value else: + # First turn the scalar into a one-element array ... if self.is_scalar: self.__value = [self.__value] + # ... then append the new value self.__value.append((ctype, value)) - def __len__(self): + def __len__(self) -> int: + """ + :return: Number of values for this argument. + """ return len(self.__value) if self.is_array else 1 - def __iter__(self): + def __iter__(self) -> Iterator['RequestArgument']: + """ + Iterate the values of this argument. Each value is accessible as if it were a scalar RequestArgument in turn, + although they are immutable. + + :return: An iterator that yields immutable views of the values. + """ if self.is_scalar: + # If this is a scalar, yield an immutable view of the single value yield _View(self.__name, self.__value) else: # Typing helper _value: List[Tuple[str, Union[bytes, str]]] = self.__value for v in _value: + # If this is an array, yield an immutable scalar view for each (ctype, value) element in the array yield _View(self.__name, v) def __getitem__(self, index: Union[int, slice]): + """ + Index the argument with either an int or a slice. The returned values are represented as immutable + RequestArgument views. Scalar arguments may be indexed with int(0). + :param index: The index or slice. + :return: An immutable view of the indexed elements of this argument. + """ if self.is_scalar: + # Scalars may only be indexed with 0 if index == 0: + # Return an immutable view of the single scalar value return _View(self.__name, self.__value) raise ValueError('Scalar RequestArgument only indexable with 0') + # Pass the index or slice through to the array, packing the result in an immutable view return _View(self.__name, self.__value[index]) class _View(RequestArgument): + """ + This class represents an immutable view of a (subset of a) RequestArgument object. Should not be instantiated + directly. + """ - def __init__(self, name: str, value: Union[Tuple[str, Union[bytes, str]], List[Tuple[str, Union[bytes, str]]]]): + def __init__(self, name: str, value: Union[Tuple[str, Union[bytes, str]], List[Tuple[str, Union[bytes, str]]]])\ + -> None: + """ + Create a new immutable view of a (subset of a) RequestArgument. + + :param name: The name for this argument, same as in the original RequestArgument. + :param value: The values to represent in this view, obtained by e.g. indexing or slicing. + """ super().__init__(name, value) @property def is_view(self) -> bool: + """ + :return: True, if this instance is an immutable view, False otherwise. + """ return True diff --git a/matemat/webserver/util.py b/matemat/webserver/util.py index 85ef721..61cf430 100644 --- a/matemat/webserver/util.py +++ b/matemat/webserver/util.py @@ -69,6 +69,7 @@ def _parse_multipart(body: bytes, boundary: str) -> List[RequestArgument]: args[name] = RequestArgument(name) args[name].append(hdr['Content-Type'].strip(), part) if not has_name: + # Content-Disposition header without name attribute raise ValueError('mutlipart/form-data part without name attribute') return list(args.values())