feat: referentiel projets dynamique + validation nommage by design
Durcit la convention de nommage des projets (dérive constatée : 'Sliding Automation', 'code_versioning'... au lieu des formes canoniques). - trilium_api.py : projets_canoniques() lit le référentiel = valeurs du label projet sur les notes de type=projet (source unique, pas de constante en dur). Note-projet CodeVersioning créée (manquait). - mcp_server.py : _valider_projet() branché dans les 6 tools de création (add_decision/history/backlog, new_conversation, create_entite, add_skill). Refuse un projet non canonique (suggestion si faute) ou inconnu (renvoi au processus de création de projet). Ne verrouille pas si référentiel illisible. - lint_audit.py : VAL-nommage aligné sur le référentiel (attrape casse, espace ET snake_case ; l'ancien 'contient un espace' ratait code_versioning). - Données : 79 notes ré-étiquetées vers les 3 formes canoniques. Quality by design : l'erreur de nommage devient impossible à l'écriture, le Lint n'est plus que le filet de sécurité.
This commit is contained in:
@@ -0,0 +1,573 @@
|
||||
import builtins
|
||||
import inspect
|
||||
import io
|
||||
import keyword
|
||||
import linecache
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import sysconfig
|
||||
import tokenize
|
||||
import traceback
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
|
||||
def is_exception_group(exc):
|
||||
return isinstance(exc, ExceptionGroup)
|
||||
|
||||
else:
|
||||
try:
|
||||
from exceptiongroup import ExceptionGroup
|
||||
except ImportError:
|
||||
|
||||
def is_exception_group(exc):
|
||||
return False
|
||||
|
||||
else:
|
||||
|
||||
def is_exception_group(exc):
|
||||
return isinstance(exc, ExceptionGroup)
|
||||
|
||||
|
||||
class SyntaxHighlighter:
|
||||
_default_style = frozenset(
|
||||
{
|
||||
"comment": "\x1b[30m\x1b[1m{}\x1b[0m",
|
||||
"keyword": "\x1b[35m\x1b[1m{}\x1b[0m",
|
||||
"builtin": "\x1b[1m{}\x1b[0m",
|
||||
"string": "\x1b[36m{}\x1b[0m",
|
||||
"number": "\x1b[34m\x1b[1m{}\x1b[0m",
|
||||
"operator": "\x1b[35m\x1b[1m{}\x1b[0m",
|
||||
"punctuation": "\x1b[1m{}\x1b[0m",
|
||||
"constant": "\x1b[36m\x1b[1m{}\x1b[0m",
|
||||
"identifier": "\x1b[1m{}\x1b[0m",
|
||||
"other": "{}",
|
||||
}.items()
|
||||
)
|
||||
|
||||
_builtins = frozenset(dir(builtins))
|
||||
_constants = frozenset({"True", "False", "None"})
|
||||
_punctuation = frozenset({"(", ")", "[", "]", "{", "}", ":", ",", ";"})
|
||||
|
||||
if sys.version_info >= (3, 12):
|
||||
_strings = frozenset(
|
||||
{tokenize.STRING, tokenize.FSTRING_START, tokenize.FSTRING_MIDDLE, tokenize.FSTRING_END}
|
||||
)
|
||||
_fstring_middle = tokenize.FSTRING_MIDDLE
|
||||
else:
|
||||
_strings = frozenset({tokenize.STRING})
|
||||
_fstring_middle = None
|
||||
|
||||
def __init__(self, style=None):
|
||||
self._style = style or dict(self._default_style)
|
||||
|
||||
def highlight(self, source):
|
||||
style = self._style
|
||||
row, column = 0, 0
|
||||
output = ""
|
||||
|
||||
for token in self.tokenize(source):
|
||||
type_, string, (start_row, start_column), (_, end_column), line = token
|
||||
|
||||
if type_ == self._fstring_middle:
|
||||
# When an f-string contains "{{" or "}}", they appear as "{" or "}" in the "string"
|
||||
# attribute of the token. However, they do not count in the column position.
|
||||
end_column += string.count("{") + string.count("}")
|
||||
|
||||
if type_ == tokenize.NAME:
|
||||
if string in self._constants:
|
||||
color = style["constant"]
|
||||
elif keyword.iskeyword(string):
|
||||
color = style["keyword"]
|
||||
elif string in self._builtins:
|
||||
color = style["builtin"]
|
||||
else:
|
||||
color = style["identifier"]
|
||||
elif type_ == tokenize.OP:
|
||||
if string in self._punctuation:
|
||||
color = style["punctuation"]
|
||||
else:
|
||||
color = style["operator"]
|
||||
elif type_ == tokenize.NUMBER:
|
||||
color = style["number"]
|
||||
elif type_ in self._strings:
|
||||
color = style["string"]
|
||||
elif type_ == tokenize.COMMENT:
|
||||
color = style["comment"]
|
||||
else:
|
||||
color = style["other"]
|
||||
|
||||
if start_row != row:
|
||||
source = source[column:]
|
||||
row, column = start_row, 0
|
||||
|
||||
if type_ != tokenize.ENCODING:
|
||||
output += line[column:start_column]
|
||||
output += color.format(line[start_column:end_column])
|
||||
|
||||
column = end_column
|
||||
|
||||
output += source[column:]
|
||||
|
||||
return output
|
||||
|
||||
@staticmethod
|
||||
def tokenize(source):
|
||||
# Worth reading: https://www.asmeurer.com/brown-water-python/
|
||||
source = source.encode("utf-8")
|
||||
source = io.BytesIO(source)
|
||||
|
||||
try:
|
||||
yield from tokenize.tokenize(source.readline)
|
||||
except tokenize.TokenError:
|
||||
return
|
||||
|
||||
|
||||
class ExceptionFormatter:
|
||||
_default_theme = frozenset(
|
||||
{
|
||||
"introduction": "\x1b[33m\x1b[1m{}\x1b[0m",
|
||||
"cause": "\x1b[1m{}\x1b[0m",
|
||||
"context": "\x1b[1m{}\x1b[0m",
|
||||
"dirname": "\x1b[32m{}\x1b[0m",
|
||||
"basename": "\x1b[32m\x1b[1m{}\x1b[0m",
|
||||
"line": "\x1b[33m{}\x1b[0m",
|
||||
"function": "\x1b[35m{}\x1b[0m",
|
||||
"exception_type": "\x1b[31m\x1b[1m{}\x1b[0m",
|
||||
"exception_value": "\x1b[1m{}\x1b[0m",
|
||||
"arrows": "\x1b[36m{}\x1b[0m",
|
||||
"value": "\x1b[36m\x1b[1m{}\x1b[0m",
|
||||
}.items()
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
colorize=False,
|
||||
backtrace=False,
|
||||
diagnose=True,
|
||||
theme=None,
|
||||
style=None,
|
||||
max_length=128,
|
||||
encoding="ascii",
|
||||
hidden_frames_filename=None,
|
||||
prefix="",
|
||||
):
|
||||
self._colorize = colorize
|
||||
self._diagnose = diagnose
|
||||
self._theme = theme or dict(self._default_theme)
|
||||
self._backtrace = backtrace
|
||||
self._syntax_highlighter = SyntaxHighlighter(style)
|
||||
self._max_length = max_length
|
||||
self._encoding = encoding
|
||||
self._hidden_frames_filename = hidden_frames_filename
|
||||
self._prefix = prefix
|
||||
self._lib_dirs = self._get_lib_dirs()
|
||||
self._pipe_char = self._get_char("\u2502", "|")
|
||||
self._cap_char = self._get_char("\u2514", "->")
|
||||
self._catch_point_identifier = " <Loguru catch point here>"
|
||||
|
||||
@staticmethod
|
||||
def _get_lib_dirs():
|
||||
schemes = sysconfig.get_scheme_names()
|
||||
names = ["stdlib", "platstdlib", "platlib", "purelib"]
|
||||
paths = {sysconfig.get_path(name, scheme) for scheme in schemes for name in names}
|
||||
return [os.path.abspath(path).lower() + os.sep for path in paths if path in sys.path]
|
||||
|
||||
@staticmethod
|
||||
def _indent(text, count, *, prefix="| "):
|
||||
if count == 0:
|
||||
yield text
|
||||
return
|
||||
for line in text.splitlines(True):
|
||||
indented = " " * count + prefix + line
|
||||
yield indented.rstrip() + "\n"
|
||||
|
||||
def _get_char(self, char, default):
|
||||
try:
|
||||
char.encode(self._encoding)
|
||||
except (UnicodeEncodeError, LookupError):
|
||||
return default
|
||||
else:
|
||||
return char
|
||||
|
||||
def _is_file_mine(self, file):
|
||||
filepath = os.path.abspath(file).lower()
|
||||
if not filepath.endswith(".py"):
|
||||
return False
|
||||
return not any(filepath.startswith(d) for d in self._lib_dirs)
|
||||
|
||||
def _extract_frames(self, tb, is_first, *, limit=None, from_decorator=False):
|
||||
frames, final_source = [], None
|
||||
|
||||
if tb is None or (limit is not None and limit <= 0):
|
||||
return frames, final_source
|
||||
|
||||
def is_valid(frame):
|
||||
return frame.f_code.co_filename != self._hidden_frames_filename
|
||||
|
||||
def get_info(frame, lineno):
|
||||
filename = frame.f_code.co_filename
|
||||
function = frame.f_code.co_name
|
||||
source = linecache.getline(filename, lineno).strip()
|
||||
return filename, lineno, function, source
|
||||
|
||||
infos = []
|
||||
|
||||
if is_valid(tb.tb_frame):
|
||||
infos.append((get_info(tb.tb_frame, tb.tb_lineno), tb.tb_frame))
|
||||
|
||||
get_parent_only = from_decorator and not self._backtrace
|
||||
|
||||
if (self._backtrace and is_first) or get_parent_only:
|
||||
frame = tb.tb_frame.f_back
|
||||
while frame:
|
||||
if is_valid(frame):
|
||||
infos.insert(0, (get_info(frame, frame.f_lineno), frame))
|
||||
if get_parent_only:
|
||||
break
|
||||
frame = frame.f_back
|
||||
|
||||
if infos and not get_parent_only:
|
||||
(filename, lineno, function, source), frame = infos[-1]
|
||||
function += self._catch_point_identifier
|
||||
infos[-1] = ((filename, lineno, function, source), frame)
|
||||
|
||||
tb = tb.tb_next
|
||||
|
||||
while tb:
|
||||
if is_valid(tb.tb_frame):
|
||||
infos.append((get_info(tb.tb_frame, tb.tb_lineno), tb.tb_frame))
|
||||
tb = tb.tb_next
|
||||
|
||||
if limit is not None:
|
||||
infos = infos[-limit:]
|
||||
|
||||
for (filename, lineno, function, source), frame in infos:
|
||||
final_source = source
|
||||
if source:
|
||||
colorize = self._colorize and self._is_file_mine(filename)
|
||||
lines = []
|
||||
if colorize:
|
||||
lines.append(self._syntax_highlighter.highlight(source))
|
||||
else:
|
||||
lines.append(source)
|
||||
if self._diagnose:
|
||||
relevant_values = self._get_relevant_values(source, frame)
|
||||
values = self._format_relevant_values(list(relevant_values), colorize)
|
||||
lines += list(values)
|
||||
source = "\n ".join(lines)
|
||||
frames.append((filename, lineno, function, source))
|
||||
|
||||
return frames, final_source
|
||||
|
||||
def _get_relevant_values(self, source, frame):
|
||||
value = None
|
||||
pending = None
|
||||
is_attribute = False
|
||||
is_valid_value = False
|
||||
is_assignment = True
|
||||
|
||||
for token in self._syntax_highlighter.tokenize(source):
|
||||
type_, string, (_, col), *_ = token
|
||||
|
||||
if pending is not None:
|
||||
# Keyword arguments are ignored
|
||||
if type_ != tokenize.OP or string != "=" or is_assignment:
|
||||
yield pending
|
||||
pending = None
|
||||
|
||||
if type_ == tokenize.NAME and not keyword.iskeyword(string):
|
||||
if not is_attribute:
|
||||
for variables in (frame.f_locals, frame.f_globals):
|
||||
try:
|
||||
value = variables[string]
|
||||
except KeyError:
|
||||
continue
|
||||
else:
|
||||
is_valid_value = True
|
||||
pending = (col, self._format_value(value))
|
||||
break
|
||||
elif is_valid_value:
|
||||
try:
|
||||
value = inspect.getattr_static(value, string)
|
||||
except AttributeError:
|
||||
is_valid_value = False
|
||||
else:
|
||||
yield (col, self._format_value(value))
|
||||
elif type_ == tokenize.OP and string == ".":
|
||||
is_attribute = True
|
||||
is_assignment = False
|
||||
elif type_ == tokenize.OP and string == ";":
|
||||
is_assignment = True
|
||||
is_attribute = False
|
||||
is_valid_value = False
|
||||
else:
|
||||
is_attribute = False
|
||||
is_valid_value = False
|
||||
is_assignment = False
|
||||
|
||||
if pending is not None:
|
||||
yield pending
|
||||
|
||||
def _format_relevant_values(self, relevant_values, colorize):
|
||||
for i in reversed(range(len(relevant_values))):
|
||||
col, value = relevant_values[i]
|
||||
pipe_cols = [pcol for pcol, _ in relevant_values[:i]]
|
||||
pre_line = ""
|
||||
index = 0
|
||||
|
||||
for pc in pipe_cols:
|
||||
pre_line += (" " * (pc - index)) + self._pipe_char
|
||||
index = pc + 1
|
||||
|
||||
pre_line += " " * (col - index)
|
||||
value_lines = value.split("\n")
|
||||
|
||||
for n, value_line in enumerate(value_lines):
|
||||
if n == 0:
|
||||
arrows = pre_line + self._cap_char + " "
|
||||
else:
|
||||
arrows = pre_line + " " * (len(self._cap_char) + 1)
|
||||
|
||||
if colorize:
|
||||
arrows = self._theme["arrows"].format(arrows)
|
||||
value_line = self._theme["value"].format(value_line)
|
||||
|
||||
yield arrows + value_line
|
||||
|
||||
def _format_value(self, v):
|
||||
try:
|
||||
v = repr(v)
|
||||
except Exception:
|
||||
v = "<unprintable %s object>" % type(v).__name__
|
||||
|
||||
max_length = self._max_length
|
||||
if max_length is not None and len(v) > max_length:
|
||||
v = v[: max_length - 3] + "..."
|
||||
return v
|
||||
|
||||
def _format_locations(self, frames_lines, *, has_introduction):
|
||||
prepend_with_new_line = has_introduction
|
||||
regex = r'^ File "(?P<file>.*?)", line (?P<line>[^,]+)(?:, in (?P<function>.*))?\n'
|
||||
|
||||
for frame in frames_lines:
|
||||
match = re.match(regex, frame)
|
||||
|
||||
if match:
|
||||
file, line, function = match.group("file", "line", "function")
|
||||
|
||||
is_mine = self._is_file_mine(file)
|
||||
|
||||
if function is not None:
|
||||
pattern = ' File "{}", line {}, in {}\n'
|
||||
else:
|
||||
pattern = ' File "{}", line {}\n'
|
||||
|
||||
if self._backtrace and function and function.endswith(self._catch_point_identifier):
|
||||
function = function[: -len(self._catch_point_identifier)]
|
||||
pattern = ">" + pattern[1:]
|
||||
|
||||
if self._colorize and is_mine:
|
||||
dirname, basename = os.path.split(file)
|
||||
if dirname:
|
||||
dirname += os.sep
|
||||
dirname = self._theme["dirname"].format(dirname)
|
||||
basename = self._theme["basename"].format(basename)
|
||||
file = dirname + basename
|
||||
line = self._theme["line"].format(line)
|
||||
function = self._theme["function"].format(function)
|
||||
|
||||
if self._diagnose and (is_mine or prepend_with_new_line):
|
||||
pattern = "\n" + pattern
|
||||
|
||||
location = pattern.format(file, line, function)
|
||||
frame = location + frame[match.end() :]
|
||||
prepend_with_new_line = is_mine
|
||||
|
||||
yield frame
|
||||
|
||||
def _format_exception(
|
||||
self, value, tb, *, seen=None, is_first=False, from_decorator=False, group_nesting=0
|
||||
):
|
||||
# Implemented from built-in traceback module:
|
||||
# https://github.com/python/cpython/blob/a5b76167/Lib/traceback.py#L468
|
||||
exc_type, exc_value, exc_traceback = type(value), value, tb
|
||||
|
||||
if seen is None:
|
||||
seen = set()
|
||||
|
||||
seen.add(id(exc_value))
|
||||
|
||||
if exc_value:
|
||||
if exc_value.__cause__ is not None and id(exc_value.__cause__) not in seen:
|
||||
yield from self._format_exception(
|
||||
exc_value.__cause__,
|
||||
exc_value.__cause__.__traceback__,
|
||||
seen=seen,
|
||||
group_nesting=group_nesting,
|
||||
)
|
||||
cause = "The above exception was the direct cause of the following exception:"
|
||||
if self._colorize:
|
||||
cause = self._theme["cause"].format(cause)
|
||||
if self._diagnose:
|
||||
yield from self._indent("\n\n" + cause + "\n\n\n", group_nesting)
|
||||
else:
|
||||
yield from self._indent("\n" + cause + "\n\n", group_nesting)
|
||||
|
||||
elif (
|
||||
exc_value.__context__ is not None
|
||||
and id(exc_value.__context__) not in seen
|
||||
and not exc_value.__suppress_context__
|
||||
):
|
||||
yield from self._format_exception(
|
||||
exc_value.__context__,
|
||||
exc_value.__context__.__traceback__,
|
||||
seen=seen,
|
||||
group_nesting=group_nesting,
|
||||
)
|
||||
context = "During handling of the above exception, another exception occurred:"
|
||||
if self._colorize:
|
||||
context = self._theme["context"].format(context)
|
||||
if self._diagnose:
|
||||
yield from self._indent("\n\n" + context + "\n\n\n", group_nesting)
|
||||
else:
|
||||
yield from self._indent("\n" + context + "\n\n", group_nesting)
|
||||
|
||||
is_grouped = is_exception_group(value)
|
||||
|
||||
if is_grouped and group_nesting == 0:
|
||||
yield from self._format_exception(
|
||||
value,
|
||||
tb,
|
||||
seen=seen,
|
||||
group_nesting=1,
|
||||
is_first=is_first,
|
||||
from_decorator=from_decorator,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
traceback_limit = sys.tracebacklimit
|
||||
except AttributeError:
|
||||
traceback_limit = None
|
||||
|
||||
frames, final_source = self._extract_frames(
|
||||
exc_traceback, is_first, limit=traceback_limit, from_decorator=from_decorator
|
||||
)
|
||||
exception_only = traceback.format_exception_only(exc_type, exc_value)
|
||||
|
||||
# Determining the correct index for the "Exception: message" part in the formatted exception
|
||||
# is challenging. This is because it might be preceded by multiple lines specific to
|
||||
# "SyntaxError" or followed by various notes. However, we can make an educated guess based
|
||||
# on the indentation; the preliminary context for "SyntaxError" is always indented, while
|
||||
# the Exception itself is not. This allows us to identify the correct index for the
|
||||
# exception message.
|
||||
no_indented_indexes = (i for i, p in enumerate(exception_only) if not p.startswith(" "))
|
||||
error_message_index = next(no_indented_indexes, None)
|
||||
|
||||
if error_message_index is not None:
|
||||
# Remove final new line temporarily.
|
||||
error_message = exception_only[error_message_index][:-1]
|
||||
|
||||
if self._colorize:
|
||||
if ":" in error_message:
|
||||
exception_type, exception_value = error_message.split(":", 1)
|
||||
exception_type = self._theme["exception_type"].format(exception_type)
|
||||
exception_value = self._theme["exception_value"].format(exception_value)
|
||||
error_message = exception_type + ":" + exception_value
|
||||
else:
|
||||
error_message = self._theme["exception_type"].format(error_message)
|
||||
|
||||
if self._diagnose and frames:
|
||||
if issubclass(exc_type, AssertionError) and not str(exc_value) and final_source:
|
||||
if self._colorize:
|
||||
final_source = self._syntax_highlighter.highlight(final_source)
|
||||
error_message += ": " + final_source
|
||||
|
||||
error_message = "\n" + error_message
|
||||
|
||||
exception_only[error_message_index] = error_message + "\n"
|
||||
|
||||
if is_first:
|
||||
yield self._prefix
|
||||
|
||||
has_introduction = bool(frames)
|
||||
|
||||
if has_introduction:
|
||||
if is_grouped:
|
||||
introduction = "Exception Group Traceback (most recent call last):"
|
||||
else:
|
||||
introduction = "Traceback (most recent call last):"
|
||||
if self._colorize:
|
||||
introduction = self._theme["introduction"].format(introduction)
|
||||
if group_nesting == 1: # Implies we're processing the root ExceptionGroup.
|
||||
yield from self._indent(introduction + "\n", group_nesting, prefix="+ ")
|
||||
else:
|
||||
yield from self._indent(introduction + "\n", group_nesting)
|
||||
|
||||
frames_lines = self._format_list(frames) + exception_only
|
||||
if self._colorize or self._backtrace or self._diagnose:
|
||||
frames_lines = self._format_locations(frames_lines, has_introduction=has_introduction)
|
||||
|
||||
yield from self._indent("".join(frames_lines), group_nesting)
|
||||
|
||||
if is_grouped:
|
||||
exc = None
|
||||
for n, exc in enumerate(value.exceptions, start=1):
|
||||
ruler = "+" + (" %s " % ("..." if n > 15 else n)).center(35, "-")
|
||||
yield from self._indent(ruler, group_nesting, prefix="+-" if n == 1 else " ")
|
||||
if n > 15:
|
||||
message = "and %d more exceptions\n" % (len(value.exceptions) - 15)
|
||||
yield from self._indent(message, group_nesting + 1)
|
||||
break
|
||||
elif group_nesting == 10 and is_exception_group(exc):
|
||||
message = "... (max_group_depth is 10)\n"
|
||||
yield from self._indent(message, group_nesting + 1)
|
||||
else:
|
||||
yield from self._format_exception(
|
||||
exc,
|
||||
exc.__traceback__,
|
||||
seen=seen,
|
||||
group_nesting=group_nesting + 1,
|
||||
)
|
||||
if not is_exception_group(exc) or group_nesting == 10:
|
||||
yield from self._indent("-" * 35, group_nesting + 1, prefix="+-")
|
||||
|
||||
def _format_list(self, frames):
|
||||
|
||||
def source_message(filename, lineno, name, line):
|
||||
message = ' File "%s", line %d, in %s\n' % (filename, lineno, name)
|
||||
if line:
|
||||
message += " %s\n" % line.strip()
|
||||
return message
|
||||
|
||||
def skip_message(count):
|
||||
plural = "s" if count > 1 else ""
|
||||
return " [Previous line repeated %d more time%s]\n" % (count, plural)
|
||||
|
||||
result = []
|
||||
count = 0
|
||||
last_source = None
|
||||
|
||||
for *source, line in frames:
|
||||
if source != last_source and count > 3:
|
||||
result.append(skip_message(count - 3))
|
||||
|
||||
if source == last_source:
|
||||
count += 1
|
||||
if count > 3:
|
||||
continue
|
||||
else:
|
||||
count = 1
|
||||
|
||||
result.append(source_message(*source, line))
|
||||
last_source = source
|
||||
|
||||
# Add a final skip message if the iteration of frames ended mid-repetition.
|
||||
if count > 3:
|
||||
result.append(skip_message(count - 3))
|
||||
|
||||
return result
|
||||
|
||||
def format_exception(self, type_, value, tb, *, from_decorator=False):
|
||||
yield from self._format_exception(value, tb, is_first=True, from_decorator=from_decorator)
|
||||
Reference in New Issue
Block a user