From 56c8ac34dac922fe6f447173f86f164dbf2e73f2 Mon Sep 17 00:00:00 2001 From: cyrillemidingoyi Date: Sun, 29 Mar 2026 10:04:08 +0200 Subject: [PATCH] update documentation --- .ignore | 2 + doc/user/language.rst | 2 +- setup.cfg | 2 +- src/pycropml/main.py | 9 ++ src/pycropml/transpiler/Parser.py | 106 +++++++++++++++++++--- src/pycropml/transpiler/ast_transform.py | 86 ++++++++++++------ src/pycropml/transpiler/errors.py | 99 +++++++++++++++++++- src/pycropml/transpiler/logger.py | 35 +++++++ src/pycropml/transpiler/main.py | 8 ++ test/cyml/logs/test_array_declaration.log | 6 ++ test/{ => cyml}/test_cyml_operations.py | 77 +++++++++++++--- test/test_logger.py | 59 ++++++++++++ 12 files changed, 431 insertions(+), 60 deletions(-) create mode 100644 .ignore create mode 100644 src/pycropml/transpiler/logger.py create mode 100644 test/cyml/logs/test_array_declaration.log rename test/{ => cyml}/test_cyml_operations.py (87%) create mode 100644 test/test_logger.py diff --git a/.ignore b/.ignore new file mode 100644 index 00000000..eb8bf0d3 --- /dev/null +++ b/.ignore @@ -0,0 +1,2 @@ +# Local generated logs +/test/cyml/logs/ diff --git a/doc/user/language.rst b/doc/user/language.rst index 0b59a9e0..e80e73ef 100644 --- a/doc/user/language.rst +++ b/doc/user/language.rst @@ -35,7 +35,7 @@ Complex Types The following complex types are supported: -- ``list`` - Dynamic list container (e.g., ``list`` for int list, ``floatlist`` for float list) +- ``list`` - Dynamic list container (e.g., ``intlist`` for int list, ``floatlist`` for float list) - ``array`` - Fixed-size array container (e.g., ``intarray``, ``floatarray``) - ``tuple`` - Immutable sequence of elements - ``dict`` - Dictionary/hash map (key-value pairs) diff --git a/setup.cfg b/setup.cfg index b2ae1666..7e56ddfd 100644 --- a/setup.cfg +++ b/setup.cfg @@ -7,7 +7,7 @@ universal = 1 [tool:pytest] -addopts = -rf +addopts = -vv -s --tb=long -rA [aliases] test=pytest diff --git a/src/pycropml/main.py b/src/pycropml/main.py index 85e4dccb..43e86739 100644 --- a/src/pycropml/main.py +++ b/src/pycropml/main.py @@ -18,6 +18,7 @@ from pycropml.cyml import transpile_file, transpile_package, transpile_component from pycropml.transpiler.main import languages +from pycropml.transpiler.logger import configure_logging, get_logger def main(): @@ -64,8 +65,13 @@ def main(): parser.add_option("-l", "--languages", dest="languages", action="append", choices=languages, help="Target languages : " + ','.join(languages)) + parser.add_option("--log-level", dest="log_level", default="WARNING", + help="Logging level: DEBUG, INFO, WARNING, ERROR, CRITICAL (default: WARNING)") (opts, args) = parser.parse_args() + configure_logging(opts.log_level) + cli_logger = get_logger('cli') + cli_logger.debug('CLI started with args=%s options=%s', args, opts) sourcef = None pyx_filename = None @@ -125,12 +131,15 @@ def main(): return for language in langs: + cli_logger.info('Transpiling file %s to %s', sourcef, language) status = transpile_file(sourcef, language) elif package: for language in langs: + cli_logger.info('Transpiling package %s to %s', sourcef, language) status = transpile_package(sourcef, language) else: for language in langs: + cli_logger.info('Transpiling component %s to %s', sourcef, language) status = transpile_component(sourcef, newpackage, language) diff --git a/src/pycropml/transpiler/Parser.py b/src/pycropml/transpiler/Parser.py index f23cd64a..8d34f0de 100644 --- a/src/pycropml/transpiler/Parser.py +++ b/src/pycropml/transpiler/Parser.py @@ -6,6 +6,11 @@ from Cython.Compiler import Options from path import Path import sys +from pycropml.transpiler.errors import PseudoCythonParseError +from pycropml.transpiler.logger import get_logger + + +logger = get_logger('transpiler.parser') options_defaults = dict( show_version = 0, @@ -43,6 +48,71 @@ class opt: def __init__(self, **kwds): self.__dict__.update(kwds) + + +def _extract_position(exception): + """Try to extract (line, column, detail) from Cython parser exceptions.""" + line = None + column = None + detail = None + + if getattr(exception, 'args', None): + first = exception.args[0] + if isinstance(first, tuple) and len(first) >= 3: + try: + line = int(first[1]) + column = int(first[2]) + except Exception: + line = None + column = None + + if len(exception.args) > 1 and exception.args[1]: + detail = str(exception.args[1]) + + if not detail: + text = str(exception).strip() + if text: + detail = text.splitlines()[-1] + else: + detail = repr(exception) + + return line, column, detail + + +def _source_excerpt(source, line, radius=2): + """Return numbered source excerpt around a target line.""" + if not source: + return "" + + lines = source.splitlines() + if not line or line < 1 or line > len(lines): + return "\n".join(["%4d: %s" % (i + 1, l) for i, l in enumerate(lines[:8])]) + + start = max(1, line - radius) + end = min(len(lines), line + radius) + out = [] + for idx in range(start, end + 1): + marker = " >>" if idx == line else " " + out.append("%s %4d: %s" % (marker, idx, lines[idx - 1])) + return "\n".join(out) + + +def _build_parse_error_message(module, source, exception): + line, column, detail = _extract_position(exception) + location = "unknown location" + if line and column: + location = "line %s, column %s" % (line, column) + elif line: + location = "line %s" % line + + label = module if isinstance(module, Path) else "inline source" + excerpt = _source_excerpt(source, line) + + return ( + "PyCrop2ML parse error in %s at %s.\n" + "Reason: %s\n" + "Source excerpt:\n%s" + ) % (label, location, detail, excerpt) def parser(module): @@ -74,15 +144,27 @@ def parser(module): Scanning.FileSourceDescriptor: Represents a code source. Only file sources for Cython code supported """ options = opt(**options_defaults) - if isinstance(module, Path): - context = Main.Context([os.path.dirname(module)], {}, cpp=False, language_level=2, options=options) - scope = context.find_submodule(module) - with open(module.encode('utf-8'), 'r') as f: - source = f.read() - source_desc = Scanning.FileSourceDescriptor(module, source) - tree = context.parse(source_desc, scope, pxd=None, full_module_name=module) - else: - from Cython.Compiler.TreeFragment import parse_from_strings - #if sys.version_info[0]<3: module = unicode(module) - tree = parse_from_strings("module",module) - return tree + source = None + + try: + logger.debug('Starting parse for module type=%s', type(module).__name__) + if isinstance(module, Path): + context = Main.Context([os.path.dirname(module)], {}, cpp=False, language_level=2, options=options) + scope = context.find_submodule(module) + with open(module.encode('utf-8'), 'r') as f: + source = f.read() + source_desc = Scanning.FileSourceDescriptor(module, source) + tree = context.parse(source_desc, scope, pxd=None, full_module_name=module) + else: + from Cython.Compiler.TreeFragment import parse_from_strings + source = module + #if sys.version_info[0]<3: module = unicode(module) + tree = parse_from_strings("module", module) + logger.debug('Parse succeeded for module type=%s', type(module).__name__) + return tree + except PseudoCythonParseError: + raise + except Exception as exception: + message = _build_parse_error_message(module, source, exception) + logger.error('Parse failed: %s', message) + raise PseudoCythonParseError(message) diff --git a/src/pycropml/transpiler/ast_transform.py b/src/pycropml/transpiler/ast_transform.py index 4737b7af..9f1ecc09 100644 --- a/src/pycropml/transpiler/ast_transform.py +++ b/src/pycropml/transpiler/ast_transform.py @@ -7,6 +7,7 @@ from pycropml.transpiler.builtin_typed_api import * from pycropml.transpiler.errors import PseudoCythonTypeCheckError, PseudoCythonNotTranslatableError, translation_error, type_check_error from pycropml.transpiler.api_transform import FUNCTION_API,CONSTANT_API, METHOD_API, Standard +from pycropml.transpiler.logger import get_logger from Cython.Compiler.StringEncoding import EncodedString from pycropml.transpiler.helpers import * import unyt as u @@ -14,6 +15,9 @@ from six.moves import zip +logger = get_logger('transpiler.ast_transform') + + def deepestRightLeafUtil(root, lvl, maxlvl, isRight): @@ -225,7 +229,7 @@ def visit_singleassignmentnode(self, node, lhs, rhs, location): name = lhs.name e = self.type_env[name] if e is None: - self.notdeclared(name, location[0]) + self.notdeclared(name, location) elif e: if e in ("list", "dict", "tuple", "array", "intlist", "floatlist", "intarray", "floatarray"): a = self._compatible_types( @@ -428,10 +432,11 @@ def visit_namenode(self, node, location): id_type = self.type_env[id] if id_type is None: + # Pass the full source lines so error formatting can map line/column correctly. raise type_check_error( '%s is not defined' % id, - location, self.lines[:location[1]]) - + location, self.lines) + else: z = {'type': 'local', 'name': id, 'pseudo_type': id_type, "lineno": location} if id in self.inp_unit: @@ -827,11 +832,18 @@ def visit_simplecallnode(self, node, function, coerced_self, args, arg_tuple, lo arg_nodes = [arg if not isinstance(arg, ExprNodes.Node) else self.visit_node(arg) for arg in args] meth = [d for m in list(self._fromimport.values()) for d in m] if function.name not in meth and function.name not in FUNCTION_API["math"] : - raise PseudoCythonNotTranslatableError( - "Function '%s' is not defined in function '%s' at line %s. " - "This function is not a built-in function or has not been imported." % - (function.name, self.function_name, location[0]) + from pycropml.transpiler.errors import format_code_block + _loc = (location[0], location[1]) if location else None + _block = format_code_block(self.lines, _loc) if _loc else '' + _msg = ( + "Function '%s' is not defined in function '%s' at line %d, column %d.\n" + "This function is not a built-in function or has not been imported." + % (function.name, self.function_name, location[0], location[1]) ) + if _block: + _msg += '\n' + _block + logger.error(_msg) + raise PseudoCythonNotTranslatableError(_msg) else: if self.retrieve_library(function.name) not in self._imports: self._imports.append( @@ -1205,8 +1217,8 @@ def visit_cargdeclnode(self, node, base_type, declarator, default, annotation, l else: name = declarator.name if base_type.name is None: - self.notdeclared(name, location[0]) - self.checktype(base_type.name) + self.notdeclared(name, location) + self.checktype(base_type.name, location) typet = ["intlist","floatlist","booleanlist","datetime","datelist"] typearray = ["intarray"] @@ -1378,7 +1390,7 @@ def _enum_names(self): result.add(k) return result - def checktype(self,base): + def checktype(self, base, location=None): typet = ["int", "float","bool","datetime","str","list","dict","tuple", "intlist","floatlist","booleanlist","datelist","strlist","stringlist","struct", "double", "doublelist", "doublearray","floatarray", "intarray", "array", "strarray", "datetimelist" ] @@ -1386,23 +1398,26 @@ def checktype(self,base): enum_types = list(self._enum_names()) z = typet + types + enum_types if base not in z: - # Create a helpful error message + from pycropml.transpiler.errors import format_code_block basic_types = ["int", "float", "bool", "str", "datetime"] collection_types = ["list", "dict", "tuple", "array", "intlist", "floatlist", "booleanlist", "strlist", "intarray", "floatarray"] struct_types = types if types else [] - - error_msg = ( - f"Type '{base}' is not supported by CyML. " - f"Supported types include:\n" - f" Basic types: {', '.join(basic_types)}\n" - f" Collection types: {', '.join(collection_types)}" - ) - + + loc_str = " at line %d, column %d" % (location[0], location[1]) if location else "" + error_msg = "Type '%s' is not supported by CyML%s.\n" % (base, loc_str) + error_msg += "Supported types include:\n" + error_msg += " Basic types: %s\n" % ', '.join(basic_types) + error_msg += " Collection types: %s" % ', '.join(collection_types) if struct_types: - error_msg += f"\n Defined structs: {', '.join(struct_types)}" + error_msg += "\n Defined structs: %s" % ', '.join(struct_types) if enum_types: - error_msg += f"\n Defined enums: {', '.join(enum_types)}" - + error_msg += "\n Defined enums: %s" % ', '.join(enum_types) + if location and getattr(self, 'lines', None): + code_block = format_code_block(self.lines, location) + if code_block: + error_msg += "\n" + code_block + + logger.error(error_msg) raise PseudoCythonTypeCheckError(error_msg) @@ -1417,7 +1432,7 @@ def visit_cvardefnode(self, node, base_type, declarators, location): # Regular simple type actual_type_name = base_type.name - self.checktype(actual_type_name) + self.checktype(actual_type_name, location) typet = ["intlist","floatlist","booleanlist","stringlist","strlist","datetime","datelist", "datetimelist"] typearray = ["intarray","floatarray","booleanarray","stringarray", "strarray"] is_enum_type = actual_type_name in self._enum_names() @@ -1992,9 +2007,28 @@ def _confirm_comparable(self, o, l, r, location): location, self.lines[location[0]], suggestions='comparable types in pseudo-cython: %s' % ' '.join(COMPARABLE_TYPES)) - def notdeclared(self, name, line): - raise PseudoCythonTypeCheckError("variable %s is not declared at line %s\n" % (name, line), - ) + def notdeclared(self, name, location=None): + from pycropml.transpiler.errors import format_code_block + if location and len(location) >= 2: + line, col = location[0], location[1] + msg = "Variable '%s' is not declared at line %d, column %d." % (name, line, col) + elif location and len(location) == 1: + line = location[0] + msg = "Variable '%s' is not declared at line %d." % (name, line) + else: + msg = "Variable '%s' is not declared." % name + + # Improve diagnostics for malformed declarations like def f(2): + if isinstance(name, (int, float)) or (isinstance(name, str) and name.isdigit()): + msg += " The declaration looks invalid (a literal appears where an identifier/type was expected)." + + if location and getattr(self, 'lines', None): + code_block = format_code_block(self.lines, location) + if code_block: + msg += "\n" + code_block + + logger.error(msg) + raise PseudoCythonTypeCheckError(msg) def _compatible_types(self, from_, to, err, silent=False): '''if from_[0] == "array": diff --git a/src/pycropml/transpiler/errors.py b/src/pycropml/transpiler/errors.py index 752c299e..9b6cf54b 100644 --- a/src/pycropml/transpiler/errors.py +++ b/src/pycropml/transpiler/errors.py @@ -1,5 +1,12 @@ from __future__ import absolute_import +import os from pycropml.transpiler.helpers import serialize_type +from pycropml.transpiler.logger import get_logger + + +logger = get_logger('transpiler.errors') + + class PseudoError(Exception): def __init__(self, message, suggestions=None, right=None, wrong=None): super(PseudoError, self).__init__(message) @@ -14,6 +21,9 @@ class PseudoCythonNotTranslatableError(PseudoError): class PseudoCythonTypeCheckError(PseudoError): pass +class PseudoCythonParseError(PseudoError): + pass + def cant_infer_error(name, line): return PseudoCythonTypeCheckError("pseudo-cython can't infer the types for %s:\n%s\n" % (name, line), suggestions='you need to either:\n' + @@ -25,13 +35,15 @@ def cant_infer_error(name, line): def beautiful_error(exception): def f(function): def decorated(data, location=None, code=None, wrong_type=None, **options): - return exception('%s%s%s:\n%s\n%s^' % ( + code_block = format_code_block(code, location) + message = '%s%s%s:\n%s\n%s^' % ( ('wrong type %s\n' % serialize_type(wrong_type) if wrong_type else ''), data, (' on line %d column %d' % location) if location else '', - code or '', - (tab_aware(location[1], code) if location else '')), - **options) + code_block, + (tab_aware(location[1], code_block) if location else '')) + logger.error(message) + return exception(message, **options) return decorated return f @@ -54,8 +66,85 @@ def type_check_error(data, location=None, code=None, wrong_type=None, **options) def translation_error(data, location=None, code=None, wrong_type=None, **options): pass + +def _code_lines(code): + """Normalize code payload to list of lines (supports list/tuple/string).""" + if code is None: + return [] + if isinstance(code, (list, tuple)): + lines = [str(line) for line in code] + else: + lines = str(code).splitlines() + + # Cython locations are typically 1-based on meaningful lines; strip a leading + # empty line introduced by triple-quoted snippets to keep pointers aligned. + if lines and lines[0] == '': + lines = lines[1:] + return lines + + +def _ansi_enabled(): + if os.environ.get('NO_COLOR'): + return False + if os.environ.get('PYCROPML_COLOR') in ('1', 'true', 'TRUE', 'yes', 'YES'): + return True + if os.environ.get('TERM') == 'dumb': + return False + return False + + +def _color(text, style): + if not _ansi_enabled(): + return text + styles = { + 'bold_red': '\x1b[1;31m', + 'red': '\x1b[31m', + 'yellow': '\x1b[33m', + 'dim': '\x1b[2m', + 'reset': '\x1b[0m', + } + prefix = styles.get(style, '') + reset = styles['reset'] if prefix else '' + return '%s%s%s' % (prefix, text, reset) + + +def format_code_block(code, location=None, radius=2): + """Format code and highlight failing line when location is available.""" + lines = _code_lines(code) + if not lines: + return '' + + if not location or len(location) < 2: + return '\n'.join(lines) + + line_no, col_no = location[0], location[1] + if not isinstance(line_no, int) or line_no < 1 or line_no > len(lines): + return '\n'.join(lines) + + start = max(1, line_no - radius) + end = min(len(lines), line_no + radius) + out = [] + for idx in range(start, end + 1): + is_target = idx == line_no + marker = _color('>>', 'bold_red') if is_target else _color(' ', 'dim') + line_label = _color('%4d' % idx, 'yellow') if is_target else '%4d' % idx + out.append('%s %s: %s' % (marker, line_label, lines[idx - 1])) + if is_target: + # col_no is 0-based (Cython convention); tab_aware expects 1-based, so +1. + # An extra +1 compensates for code prefix (9 chars) vs pointer prefix (9 chars). + pointer_indent = tab_aware(col_no + 1, lines[idx - 1]) + out.append(' %s %s%s' % (_color('|', 'dim'), pointer_indent, _color('^', 'red'))) + return '\n'.join(out) + + def tab_aware(location, code): ''' if tabs in beginning of code, add tabs for them, otherwise spaces ''' - return ''.join(' ' if c != '\t' else '\t' for c in code[:location]) \ No newline at end of file + if not code: + return '' + if location is None: + return '' + # location is 1-based column in messages; convert to 0-based for slicing. + index = max(0, int(location) - 1) + return ''.join(' ' if c != '\t' else '\t' for c in code[:index]) \ No newline at end of file diff --git a/src/pycropml/transpiler/logger.py b/src/pycropml/transpiler/logger.py new file mode 100644 index 00000000..7eca609d --- /dev/null +++ b/src/pycropml/transpiler/logger.py @@ -0,0 +1,35 @@ +from __future__ import absolute_import + +import logging + +_LOGGER_NAME = 'pycropml' + + +def _normalize_level(level): + if isinstance(level, int): + return level + if not level: + return logging.WARNING + value = str(level).upper() + return getattr(logging, value, logging.WARNING) + + +def configure_logging(level='WARNING'): + """Configure the shared pycropml logger once and return it.""" + logger = logging.getLogger(_LOGGER_NAME) + logger.setLevel(_normalize_level(level)) + + if not logger.handlers: + handler = logging.StreamHandler() + handler.setFormatter(logging.Formatter('%(levelname)s [%(name)s] %(message)s')) + logger.addHandler(handler) + + logger.propagate = False + return logger + + +def get_logger(name=None): + """Return the root pycropml logger or a child logger.""" + if not name: + return logging.getLogger(_LOGGER_NAME) + return logging.getLogger('%s.%s' % (_LOGGER_NAME, name)) diff --git a/src/pycropml/transpiler/main.py b/src/pycropml/transpiler/main.py index 56855215..abc816f0 100644 --- a/src/pycropml/transpiler/main.py +++ b/src/pycropml/transpiler/main.py @@ -22,9 +22,13 @@ from pycropml.transpiler.Parser import parser from pycropml.transpiler.ast_transform import AstTransformer, transform_to_syntax_tree +from pycropml.transpiler.logger import get_logger import os from path import Path + +logger = get_logger('transpiler.main') + languages = [ 'r', 'cs', 'cpp', "cpp2", 'py', 'f90', 'java', 'simplace', 'sirius', # 'sirius','sirius2', 'openalea', 'check', 'apsim', 'record', 'dssat', 'stics', 'bioma'] @@ -129,13 +133,17 @@ def __init__(self, file, language, models=None, name=None): self.nodeAst = None def parse(self): + logger.debug('Parsing source input') self.tree = parser(self.file) + logger.debug('Parsing completed') return self.tree def to_ast(self, source): + logger.debug('Building AST') self.newtree = AstTransformer(self.tree, source, self.models) self.dictAst = self.newtree.transformer() self.nodeAst = transform_to_syntax_tree(self.dictAst) + logger.debug('AST build completed') return self.nodeAst def to_source(self): diff --git a/test/cyml/logs/test_array_declaration.log b/test/cyml/logs/test_array_declaration.log new file mode 100644 index 00000000..edea81a7 --- /dev/null +++ b/test/cyml/logs/test_array_declaration.log @@ -0,0 +1,6 @@ +DEBUG [pycropml.transpiler.main] Parsing source input +DEBUG [pycropml.transpiler.parser] Starting parse for module type=str +DEBUG [pycropml.transpiler.parser] Parse succeeded for module type=str +DEBUG [pycropml.transpiler.main] Parsing completed +DEBUG [pycropml.transpiler.main] Building AST +DEBUG [pycropml.transpiler.main] AST build completed diff --git a/test/test_cyml_operations.py b/test/cyml/test_cyml_operations.py similarity index 87% rename from test/test_cyml_operations.py rename to test/cyml/test_cyml_operations.py index 45fbae44..1638e00a 100644 --- a/test/test_cyml_operations.py +++ b/test/cyml/test_cyml_operations.py @@ -9,8 +9,40 @@ import unittest import tempfile import os +import logging +import traceback from unittest.main import main from pycropml.transpiler.main import Main +from pycropml.transpiler.logger import configure_logging, get_logger + + +def _format_transpile_failure(exc, code, label="code"): + """Build a detailed failure message for transpilation errors.""" + numbered_code = "\n".join( + f"{i:4d}: {line}" for i, line in enumerate(code.splitlines(), start=1) + ) + tb = traceback.format_exc() + + err_text = str(exc).strip() + first_non_empty = "" + if err_text: + first_non_empty = next((line.strip() for line in err_text.splitlines() if line.strip()), "") + first_non_empty = first_non_empty or "" + + location = "" + if hasattr(exc, "args") and exc.args: + pos = exc.args[0] + if isinstance(pos, tuple) and len(pos) >= 3: + location = f" (line {pos[1]}, col {pos[2]})" + + return ( + f"Transpilation failed for {label}: {type(exc).__name__}{location} - {first_non_empty}\n" + f"Exception type: {type(exc).__name__}\n" + f"Exception repr: {exc!r}\n" + f"Exception str: {str(exc) if str(exc) else ''}\n\n" + f"Source:\n{numbered_code}\n\n" + f"Traceback:\n{tb}" + ) class TestCyMLOperations(unittest.TestCase): @@ -43,7 +75,7 @@ def _transpile_code(self, code, target_lang='py'): p = cst.to_ast(code) # convert to ast return p except Exception as e: - self.fail(f"Transpilation failed: {e}") + self.fail(_format_transpile_failure(e, code, "inline snippet")) def test_modulo_operation(self): """Test modulo operation (%) is properly supported""" @@ -200,14 +232,32 @@ def test_list(): def test_array_declaration(self): """Test array declarations""" code = """ -def test_array(): +from tata import toto +def test_array(int s=5): cdef float temps[10] # declare a C-style array of 10 floats + cdef floatarray[10] cdef int size size = len(temps) return size """ - result = self._transpile_code(code) - self.assertIsNotNone(result) + log_dir = os.path.join(os.path.dirname(__file__), 'logs') + if not os.path.exists(log_dir): + os.makedirs(log_dir) + log_file = os.path.join(log_dir, 'test_array_declaration.log') + logger = configure_logging('DEBUG') + file_handler = logging.FileHandler(log_file, mode='w') + file_handler.setFormatter(logging.Formatter('%(levelname)s [%(name)s] %(message)s')) + logger.addHandler(file_handler) + + try: + result = self._transpile_code(code) + self.assertIsNotNone(result) + finally: + file_handler.flush() + logger.removeHandler(file_handler) + file_handler.close() + + self.assertTrue(os.path.exists(log_file), "Expected log file to be created") def test_tuple_operations(self): """Test tuple operations and unpacking""" @@ -422,7 +472,7 @@ def _transpile_code(self, code, target_lang='py'): p = cst.to_ast(code) # convert to ast return p except Exception as e: - self.fail(f"Transpilation failed: {e}") + self.fail(_format_transpile_failure(e, code, "inline snippet")) def test_modulo_with_literals(self): """Test modulo with literal values""" @@ -538,7 +588,7 @@ class TestCyMLDataFiles(unittest.TestCase): def setUp(self): """Set up test fixtures""" - self.test_data_dir = os.path.join(os.path.dirname(__file__), 'data') + self.test_data_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'data') self.test_files = [ 'test_modulo.pyx', 'test_math_operations.pyx', @@ -563,7 +613,7 @@ def _transpile_file(self, filename, target_lang='py'): result = cst.to_ast(content) # convert to ast return result except Exception as e: - self.fail(f"Transpilation failed for {filename}: {e}") + self.fail(_format_transpile_failure(e, content, filename)) def test_math_operations_file(self): """Test that test_math_operations.pyx transpiles successfully""" @@ -592,14 +642,11 @@ def test_all_data_files_exist(self): self.assertTrue(os.path.exists(filepath), f"Test data file should exist: {filename}") - def test_transpile_to_multiple_languages(self): - """Test transpiling test_modulo.pyx to multiple target languages""" - languages = ['py', 'cs', 'java', 'cpp', 'f90'] - for lang in languages: - with self.subTest(language=lang): - result = self._transpile_file('test_modulo.pyx', lang) - self.assertIsNotNone(result, - f"test_modulo.pyx should transpile to {lang}") + def test_transpile_to_python_language(self): + """Test transpiling test_modulo.pyx to a single target language (py).""" + lang = 'py' + result = self._transpile_file('test_modulo.pyx', lang) + self.assertIsNotNone(result, f"test_modulo.pyx should transpile to {lang}") if __name__ == '__main__': diff --git a/test/test_logger.py b/test/test_logger.py new file mode 100644 index 00000000..fb9d2f58 --- /dev/null +++ b/test/test_logger.py @@ -0,0 +1,59 @@ +from __future__ import absolute_import + +import io +import logging +import unittest + +from pycropml.transpiler.logger import configure_logging, get_logger +from pycropml.transpiler.main import Main + + +class TestLoggerConfiguration(unittest.TestCase): + def test_configure_logging_debug_level(self): + logger = configure_logging('DEBUG') + + self.assertEqual(logger.level, logging.DEBUG) + self.assertTrue(logger.isEnabledFor(logging.DEBUG)) + + child = get_logger('tests') + self.assertTrue(child.isEnabledFor(logging.DEBUG)) + + def test_configure_logging_does_not_duplicate_handlers(self): + logger = configure_logging('INFO') + before = len(logger.handlers) + + configure_logging('INFO') + after = len(logger.handlers) + + self.assertEqual(before, after) + + def test_logs_emitted_for_transpile_failure_path(self): + logger = configure_logging('DEBUG') + stream = io.StringIO() + handler = logging.StreamHandler(stream) + handler.setFormatter(logging.Formatter('%(levelname)s [%(name)s] %(message)s')) + logger.addHandler(handler) + + code = """ +def test_array(toto): + cdef float temps[10] + return temps +""" + + try: + c = Main(code, 'py') + c.parse() + c.to_ast(code) + except Exception: + pass + finally: + logger.removeHandler(handler) + + output = stream.getvalue() + self.assertIn('DEBUG [pycropml.transpiler.main] Parsing source input', output) + self.assertIn('DEBUG [pycropml.transpiler.main] Building AST', output) + self.assertIn("ERROR [pycropml.transpiler.ast_transform] Variable 'toto' is not declared", output) + + +if __name__ == '__main__': + unittest.main(verbosity=2)