feat(assessment): auto comprehensive assessment - rules engine from college scoring standard, per-semester stats, manual overrides, word export

This commit is contained in:
root
2026-10-17 15:40:07 +08:00
parent a0e2ded426
commit 49d8400722
2395 changed files with 1306906 additions and 94 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,136 @@
import email.message
import email.policy
import re
import textwrap
from ._text import FoldedCase
class RawPolicy(email.policy.EmailPolicy):
def fold(self, name, value):
folded = self.linesep.join(
textwrap
.indent(value, prefix=' ' * 8, predicate=lambda line: True)
.lstrip()
.splitlines()
)
return f'{name}: {folded}{self.linesep}'
class Message(email.message.Message):
r"""
Specialized Message subclass to handle metadata naturally.
Reads values that may have newlines in them and converts the
payload to the Description.
>>> msg_text = textwrap.dedent('''
... Name: Foo
... Version: 3.0
... License: blah
... de-blah
... <BLANKLINE>
... First line of description.
... Second line of description.
... <BLANKLINE>
... Fourth line!
... ''').lstrip().replace('<BLANKLINE>', '')
>>> msg = Message(email.message_from_string(msg_text))
>>> msg['Description']
'First line of description.\nSecond line of description.\n\nFourth line!\n'
Message should render even if values contain newlines.
>>> print(msg)
Name: Foo
Version: 3.0
License: blah
de-blah
Description: First line of description.
Second line of description.
<BLANKLINE>
Fourth line!
<BLANKLINE>
<BLANKLINE>
"""
multiple_use_keys = set(
map(
FoldedCase,
[
'Classifier',
'Obsoletes-Dist',
'Platform',
'Project-URL',
'Provides-Dist',
'Provides-Extra',
'Requires-Dist',
'Requires-External',
'Supported-Platform',
'Dynamic',
],
)
)
"""
Keys that may be indicated multiple times per PEP 566.
"""
def __new__(cls, orig: email.message.Message):
res = super().__new__(cls)
vars(res).update(vars(orig))
return res
def __init__(self, *args, **kwargs):
self._headers = self._repair_headers()
# suppress spurious error from mypy
def __iter__(self):
return super().__iter__()
def __getitem__(self, item):
"""
Override parent behavior to typical dict behavior.
``email.message.Message`` will emit None values for missing
keys. Typical mappings, including this ``Message``, will raise
a key error for missing keys.
Ref python/importlib_metadata#371.
"""
res = super().__getitem__(item)
if res is None:
raise KeyError(item)
return res
def _repair_headers(self):
def redent(value):
"Correct for RFC822 indentation"
indent = ' ' * 8
if not value or '\n' + indent not in value:
return value
return textwrap.dedent(indent + value)
headers = [(key, redent(value)) for key, value in vars(self)['_headers']]
if self._payload:
headers.append(('Description', self.get_payload()))
self.set_payload('')
return headers
def as_string(self):
return super().as_string(policy=RawPolicy())
@property
def json(self):
"""
Convert PackageMetadata to a JSON-compatible format
per PEP 0566.
"""
def transform(key):
value = self.get_all(key) if key in self.multiple_use_keys else self[key]
if key == 'Keywords':
value = re.split(r'\s+', value)
tk = key.lower().replace('-', '_')
return tk, value
return dict(map(transform, map(FoldedCase, self)))
@@ -0,0 +1,34 @@
import collections
import typing
# from jaraco.collections 3.3
class FreezableDefaultDict(collections.defaultdict):
"""
Often it is desirable to prevent the mutation of
a default dict after its initial construction, such
as to prevent mutation during iteration.
>>> dd = FreezableDefaultDict(list)
>>> dd[0].append('1')
>>> dd.freeze()
>>> dd[1]
[]
>>> len(dd)
1
"""
def __missing__(self, key):
return getattr(self, '_frozen', super().__missing__)(key)
def freeze(self):
self._frozen = lambda key: self.default_factory()
class Pair(typing.NamedTuple):
name: str
value: str
@classmethod
def parse(cls, text):
return cls(*map(str.strip, text.split("=", 1)))
@@ -0,0 +1,118 @@
from __future__ import annotations
import functools
import operator
# from jaraco.context 6.1
class ExceptionTrap:
"""
A context manager that will catch certain exceptions and provide an
indication they occurred.
>>> with ExceptionTrap() as trap:
... raise Exception()
>>> bool(trap)
True
>>> with ExceptionTrap() as trap:
... pass
>>> bool(trap)
False
>>> with ExceptionTrap(ValueError) as trap:
... raise ValueError("1 + 1 is not 3")
>>> bool(trap)
True
>>> trap.value
ValueError('1 + 1 is not 3')
>>> trap.tb
<traceback object at ...>
>>> with ExceptionTrap(ValueError) as trap:
... raise Exception()
Traceback (most recent call last):
...
Exception
>>> bool(trap)
False
"""
exc_info = None, None, None
def __init__(self, exceptions=(Exception,)):
self.exceptions = exceptions
def __enter__(self):
return self
@property
def type(self):
return self.exc_info[0]
@property
def value(self):
return self.exc_info[1]
@property
def tb(self):
return self.exc_info[2]
def __exit__(self, *exc_info):
type = exc_info[0]
matches = type and issubclass(type, self.exceptions)
if matches:
self.exc_info = exc_info
return matches
def __bool__(self):
return bool(self.type)
def raises(self, func, *, _test=bool):
"""
Wrap func and replace the result with the truth
value of the trap (True if an exception occurred).
First, give the decorator an alias to support Python 3.8
Syntax.
>>> raises = ExceptionTrap(ValueError).raises
Now decorate a function that always fails.
>>> @raises
... def fail():
... raise ValueError('failed')
>>> fail()
True
"""
@functools.wraps(func)
def wrapper(*args, **kwargs):
with ExceptionTrap(self.exceptions) as trap:
func(*args, **kwargs)
return _test(trap)
return wrapper
def passes(self, func):
"""
Wrap func and replace the result with the truth
value of the trap (True if no exception).
First, give the decorator an alias to support Python 3.8
Syntax.
>>> passes = ExceptionTrap(ValueError).passes
Now decorate a function that always fails.
>>> @passes
... def fail():
... raise ValueError('failed')
>>> fail()
False
"""
return self.raises(func, _test=operator.not_)
@@ -0,0 +1,136 @@
import functools
import types
from collections.abc import Callable
from typing import TypeVar
# from jaraco.functools 3.3
def method_cache(method, cache_wrapper=None):
"""
Wrap lru_cache to support storing the cache data in the object instances.
Abstracts the common paradigm where the method explicitly saves an
underscore-prefixed protected property on first call and returns that
subsequently.
>>> class MyClass:
... calls = 0
...
... @method_cache
... def method(self, value):
... self.calls += 1
... return value
>>> a = MyClass()
>>> a.method(3)
3
>>> for x in range(75):
... res = a.method(x)
>>> a.calls
75
Note that the apparent behavior will be exactly like that of lru_cache
except that the cache is stored on each instance, so values in one
instance will not flush values from another, and when an instance is
deleted, so are the cached values for that instance.
>>> b = MyClass()
>>> for x in range(35):
... res = b.method(x)
>>> b.calls
35
>>> a.method(0)
0
>>> a.calls
75
Note that if method had been decorated with ``functools.lru_cache()``,
a.calls would have been 76 (due to the cached value of 0 having been
flushed by the 'b' instance).
Clear the cache with ``.cache_clear()``
>>> a.method.cache_clear()
Same for a method that hasn't yet been called.
>>> c = MyClass()
>>> c.method.cache_clear()
Another cache wrapper may be supplied:
>>> cache = functools.lru_cache(maxsize=2)
>>> MyClass.method2 = method_cache(lambda self: 3, cache_wrapper=cache)
>>> a = MyClass()
>>> a.method2()
3
Caution - do not subsequently wrap the method with another decorator, such
as ``@property``, which changes the semantics of the function.
See also
http://code.activestate.com/recipes/577452-a-memoize-decorator-for-instance-methods/
for another implementation and additional justification.
"""
cache_wrapper = cache_wrapper or functools.lru_cache()
def wrapper(self, *args, **kwargs):
# it's the first call, replace the method with a cached, bound method
bound_method = types.MethodType(method, self)
cached_method = cache_wrapper(bound_method)
setattr(self, method.__name__, cached_method)
return cached_method(*args, **kwargs)
# Support cache clear even before cache has been created.
wrapper.cache_clear = lambda: None
return wrapper
# From jaraco.functools 3.3
def pass_none(func):
"""
Wrap func so it's not called if its first param is None
>>> print_text = pass_none(print)
>>> print_text('text')
text
>>> print_text(None)
"""
@functools.wraps(func)
def wrapper(param, *args, **kwargs):
if param is not None:
return func(param, *args, **kwargs)
return wrapper
# From jaraco.functools 4.4
def noop(*args, **kwargs):
"""
A no-operation function that does nothing.
>>> noop(1, 2, three=3)
"""
_T = TypeVar('_T')
# From jaraco.functools 4.4
def passthrough(func: Callable[..., object]) -> Callable[[_T], _T]:
"""
Wrap the function to always return the first parameter.
>>> passthrough(print)('3')
3
'3'
"""
@functools.wraps(func)
def wrapper(first: _T, *args, **kwargs) -> _T:
func(first, *args, **kwargs)
return first
return wrapper # type: ignore[return-value]
@@ -0,0 +1,171 @@
from collections import defaultdict, deque
from itertools import filterfalse
def unique_everseen(iterable, key=None):
"List unique elements, preserving order. Remember all elements ever seen."
# unique_everseen('AAAABBBCCDAABBB') --> A B C D
# unique_everseen('ABBCcAD', str.lower) --> A B C D
seen = set()
seen_add = seen.add
if key is None:
for element in filterfalse(seen.__contains__, iterable):
seen_add(element)
yield element
else:
for element in iterable:
k = key(element)
if k not in seen:
seen_add(k)
yield element
# copied from more_itertools 8.8
def always_iterable(obj, base_type=(str, bytes)):
"""If *obj* is iterable, return an iterator over its items::
>>> obj = (1, 2, 3)
>>> list(always_iterable(obj))
[1, 2, 3]
If *obj* is not iterable, return a one-item iterable containing *obj*::
>>> obj = 1
>>> list(always_iterable(obj))
[1]
If *obj* is ``None``, return an empty iterable:
>>> obj = None
>>> list(always_iterable(None))
[]
By default, binary and text strings are not considered iterable::
>>> obj = 'foo'
>>> list(always_iterable(obj))
['foo']
If *base_type* is set, objects for which ``isinstance(obj, base_type)``
returns ``True`` won't be considered iterable.
>>> obj = {'a': 1}
>>> list(always_iterable(obj)) # Iterate over the dict's keys
['a']
>>> list(always_iterable(obj, base_type=dict)) # Treat dicts as a unit
[{'a': 1}]
Set *base_type* to ``None`` to avoid any special handling and treat objects
Python considers iterable as iterable:
>>> obj = 'foo'
>>> list(always_iterable(obj, base_type=None))
['f', 'o', 'o']
"""
if obj is None:
return iter(())
if (base_type is not None) and isinstance(obj, base_type):
return iter((obj,))
try:
return iter(obj)
except TypeError:
return iter((obj,))
# Copied from more_itertools 10.3
class bucket:
"""Wrap *iterable* and return an object that buckets the iterable into
child iterables based on a *key* function.
>>> iterable = ['a1', 'b1', 'c1', 'a2', 'b2', 'c2', 'b3']
>>> s = bucket(iterable, key=lambda x: x[0]) # Bucket by 1st character
>>> sorted(list(s)) # Get the keys
['a', 'b', 'c']
>>> a_iterable = s['a']
>>> next(a_iterable)
'a1'
>>> next(a_iterable)
'a2'
>>> list(s['b'])
['b1', 'b2', 'b3']
The original iterable will be advanced and its items will be cached until
they are used by the child iterables. This may require significant storage.
By default, attempting to select a bucket to which no items belong will
exhaust the iterable and cache all values.
If you specify a *validator* function, selected buckets will instead be
checked against it.
>>> from itertools import count
>>> it = count(1, 2) # Infinite sequence of odd numbers
>>> key = lambda x: x % 10 # Bucket by last digit
>>> validator = lambda x: x in {1, 3, 5, 7, 9} # Odd digits only
>>> s = bucket(it, key=key, validator=validator)
>>> 2 in s
False
>>> list(s[2])
[]
"""
def __init__(self, iterable, key, validator=None):
self._it = iter(iterable)
self._key = key
self._cache = defaultdict(deque)
self._validator = validator or (lambda x: True)
def __contains__(self, value):
if not self._validator(value):
return False
try:
item = next(self[value])
except StopIteration:
return False
else:
self._cache[value].appendleft(item)
return True
def _get_values(self, value):
"""
Helper to yield items from the parent iterator that match *value*.
Items that don't match are stored in the local cache as they
are encountered.
"""
while True:
# If we've cached some items that match the target value, emit
# the first one and evict it from the cache.
if self._cache[value]:
yield self._cache[value].popleft()
# Otherwise we need to advance the parent iterator to search for
# a matching item, caching the rest.
else:
while True:
try:
item = next(self._it)
except StopIteration:
return
item_value = self._key(item)
if item_value == value:
yield item
break
elif self._validator(item_value):
self._cache[item_value].append(item)
def __iter__(self):
for item in self._it:
item_value = self._key(item)
if self._validator(item_value):
self._cache[item_value].append(item)
yield from self._cache.keys()
def __getitem__(self, value):
if not self._validator(value):
return iter(())
return self._get_values(value)
@@ -0,0 +1,71 @@
from __future__ import annotations
import os
from collections.abc import Iterator
from typing import (
Any,
Protocol,
TypeVar,
overload,
)
_T = TypeVar("_T")
class PackageMetadata(Protocol):
def __len__(self) -> int: ... # pragma: no cover
def __contains__(self, item: str) -> bool: ... # pragma: no cover
def __getitem__(self, key: str) -> str: ... # pragma: no cover
def __iter__(self) -> Iterator[str]: ... # pragma: no cover
@overload
def get(
self, name: str, failobj: None = None
) -> str | None: ... # pragma: no cover
@overload
def get(self, name: str, failobj: _T) -> str | _T: ... # pragma: no cover
# overload per python/importlib_metadata#435
@overload
def get_all(
self, name: str, failobj: None = None
) -> list[Any] | None: ... # pragma: no cover
@overload
def get_all(self, name: str, failobj: _T) -> list[Any] | _T:
"""
Return all values associated with a possibly multi-valued key.
"""
@property
def json(self) -> dict[str, str | list[str]]:
"""
A JSON-compatible form of the metadata.
"""
class SimplePath(Protocol):
"""
A minimal subset of pathlib.Path required by Distribution.
"""
def joinpath(
self, other: str | os.PathLike[str]
) -> SimplePath: ... # pragma: no cover
def __truediv__(
self, other: str | os.PathLike[str]
) -> SimplePath: ... # pragma: no cover
@property
def parent(self) -> SimplePath: ... # pragma: no cover
def read_text(self, encoding=None) -> str: ... # pragma: no cover
def read_bytes(self) -> bytes: ... # pragma: no cover
def exists(self) -> bool: ... # pragma: no cover
@@ -0,0 +1,99 @@
import re
from ._functools import method_cache
# from jaraco.text 3.5
class FoldedCase(str):
"""
A case insensitive string class; behaves just like str
except compares equal when the only variation is case.
>>> s = FoldedCase('hello world')
>>> s == 'Hello World'
True
>>> 'Hello World' == s
True
>>> s != 'Hello World'
False
>>> s.index('O')
4
>>> s.split('O')
['hell', ' w', 'rld']
>>> sorted(map(FoldedCase, ['GAMMA', 'alpha', 'Beta']))
['alpha', 'Beta', 'GAMMA']
Sequence membership is straightforward.
>>> "Hello World" in [s]
True
>>> s in ["Hello World"]
True
You may test for set inclusion, but candidate and elements
must both be folded.
>>> FoldedCase("Hello World") in {s}
True
>>> s in {FoldedCase("Hello World")}
True
String inclusion works as long as the FoldedCase object
is on the right.
>>> "hello" in FoldedCase("Hello World")
True
But not if the FoldedCase object is on the left:
>>> FoldedCase('hello') in 'Hello World'
False
In that case, use in_:
>>> FoldedCase('hello').in_('Hello World')
True
>>> FoldedCase('hello') > FoldedCase('Hello')
False
"""
def __lt__(self, other):
return self.lower() < other.lower()
def __gt__(self, other):
return self.lower() > other.lower()
def __eq__(self, other):
return self.lower() == other.lower()
def __ne__(self, other):
return self.lower() != other.lower()
def __hash__(self):
return hash(self.lower())
def __contains__(self, other):
return super().lower().__contains__(other.lower())
def in_(self, other):
"Does self appear in other?"
return self in FoldedCase(other)
# cache lower since it's likely to be called frequently.
@method_cache
def lower(self):
return super().lower()
def index(self, sub):
return self.lower().index(sub.lower())
def split(self, splitter=' ', maxsplit=0):
pattern = re.compile(re.escape(splitter), re.I)
return pattern.split(self, maxsplit)
@@ -0,0 +1,15 @@
import functools
import typing
from ._meta import PackageMetadata
md_none = functools.partial(typing.cast, PackageMetadata)
"""
Suppress type errors for optional metadata.
Although Distribution.metadata can return None when metadata is corrupt
and thus None, allow callers to assume it's not None and crash if
that's the case.
# python/importlib_metadata#493
"""
@@ -0,0 +1,21 @@
import sys
from . import Distribution
def inspect(path):
print("Inspecting", path)
dists = list(Distribution.discover(path=[path]))
if not dists:
return
print("Found", len(dists), "packages:", end=' ')
print(', '.join(dist.name for dist in dists))
def run():
for path in sys.path:
inspect(path)
if __name__ == '__main__':
run()