diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 4d079539c..ea91d6162 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -53,7 +53,6 @@ jobs: - {name: "mkl linux", os: ubuntu-latest, python-version: "3.12", matrix-backend: mkl, nprocs: 1} - {name: "mkl linux parallel", os: ubuntu-latest, python-version: "3.12", matrix-backend: mkl, nprocs: 2} - {name: "mkl windows", os: windows-latest, python-version: "3.12", matrix-backend: mkl, nprocs: 1} - - {name: "mkl macos", os: macos-latest, python-version: "3.12", matrix-backend: mkl, nprocs: 1} - {name: "parallel", os: ubuntu-latest, python-version: "3.12", matrix-backend: numpy, nprocs: 2} - {name: "numpy 1.17", os: ubuntu-latest, python-version: "3.8", matrix-backend: numpy, nprocs: 1, numpy-version: ==1.17.3} - {name: "tensorial", os: ubuntu-latest, python-version: "3.12", matrix-backend: numpy, nprocs: 1, tensorial: test} @@ -92,7 +91,7 @@ jobs: _numpy_version: ${{ matrix.numpy-version }} run: | python -um pip install --upgrade --upgrade-strategy eager wheel - python -um pip install --upgrade --upgrade-strategy eager coverage numpy$_numpy_version + python -um pip install --upgrade --upgrade-strategy eager numpy$_numpy_version # Install Nutils from `dist` dir created in job `build-python-package`. python -um pip install "$_wheel[import_gmsh,export_mpl]" - name: Install Scipy @@ -104,11 +103,40 @@ jobs: python -um pip install --upgrade --upgrade-strategy eager mkl python -um devtools.gha.configure_mkl - name: Test - run: python -um coverage run -m unittest discover -b -q -t . -s tests - - name: Post-process coverage - run: python -um devtools.gha.coverage_report_xml - - name: Upload coverage - uses: codecov/codecov-action@v3 + env: + COVERAGE_ID: ${{ matrix.name }} + run: python -um devtools.gha.unittest + - name: Upload coverage artifact + uses: actions/upload-artifact@v4 + with: + name: _coverage_${{ matrix.name }} + path: target/coverage/ + if-no-files-found: error + process-coverage: + if: ${{ always() }} + needs: test + name: 'Test coverage' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Download coverage artifacts + uses: actions/download-artifact@v4 + with: + pattern: _coverage_* + path: target/coverage + merge-multiple: true + - name: Generate summary + run: python -um devtools.gha.report_coverage + - name: Upload lcov artifact + uses: actions/upload-artifact@v4 + with: + name: coverage + path: target/coverage/coverage.info + - name: Delete temporary coverage artifacts + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: python -um devtools.gha.delete_coverage_artifacts test-examples: needs: build-python-package name: 'Test examples ${{ matrix.os }}' diff --git a/devtools/_log_default.py b/devtools/_log_default.py index 65e775754..61ca871b4 100644 --- a/devtools/_log_default.py +++ b/devtools/_log_default.py @@ -1,26 +1,52 @@ -from typing import Any +import functools +from typing import Any, Optional, Union -debug = print +def _log_msg(*msg, color: Optional[str] = None, title: Optional[str] = None, file: Optional[str] = None, line: Union[int, range, None] = None, column: Union[int, range, None] = None): + params = [] -def info(*args: Any) -> None: - print('\033[1;37m', end='') - for line in ' '.join(map(str, args)).split('\n'): - print(line) - print('\033[0m', end='', flush=True) + if file: + params.append(f'file={file}') + if isinstance(line, range) and line.start == line.stop + 1 and line.step == 1: + line = line.stop + if isinstance(line, int): + params.append(f'line={line}') + elif isinstance(line, range) and line.start < line.stop and line.step == 1: + params.append(f'lines={line.start}-{line.stop-1}') -def warning(*args: Any) -> None: - print('\033[1;33m', end='') - for line in ' '.join(map(str, args)).split('\n'): - print('WARNING: {}'.format(line)) - print('\033[0m', end='', flush=True) + if isinstance(column, range) and column.start == column.stop + 1 and column.step == 1: + column = column.stop + if isinstance(column, int): + params.append(f'column={column}') + elif isinstance(column, range) and column.start < column.stop and column.step == 1: + params.append(f'columns={column.start}-{column.stop-1}') + + if title: + params.append(f'title={title}') + + if color: + print(f'\033[{color}m', end='') + if params: + print('--', ','.join(params)) -def error(*args: Any) -> None: - print('\033[1;31m', end='') + print(*msg) + + if color: + print(f'\033[0m', end='') + + +debug = functools.partial(_log_msg) +notice = functools.partial(_log_msg, type='1;36') +warning = functools.partial(_log_msg, color='1;33') +error = functools.partial(_log_msg, type='1;31') + + +def info(*args: Any) -> None: + print('\033[1;37m', end='') for line in ' '.join(map(str, args)).split('\n'): - print('ERROR: {}'.format(line)) + print(line) print('\033[0m', end='', flush=True) diff --git a/devtools/_log_gha.py b/devtools/_log_gha.py index 4f09260b4..eafe5acea 100644 --- a/devtools/_log_gha.py +++ b/devtools/_log_gha.py @@ -1,25 +1,51 @@ +import functools import os from pathlib import Path -from typing import Any +from typing import Any, Optional, Union -debug = print +def _log_msg(*msg, type: str, title: Optional[str] = None, file: Optional[str] = None, line: Union[int, range, None] = None, column: Union[int, range, None] = None): + params = [] -def info(*args: Any) -> None: - print('\033[1;37m', end='') - for line in ' '.join(map(str, args)).split('\n'): - print(line) - print('\033[0m', end='', flush=True) + if file: + params.append(f'file={file}') + if isinstance(line, range) and line.start == line.stop + 1 and line.step == 1: + line = line.stop + if isinstance(line, int): + params.append(f'line={line}') + elif isinstance(line, range) and line.start < line.stop and line.step == 1: + params.append(f'line={line.start}') + params.append(f'endLine={line.stop-1}') -def warning(*args: Any) -> None: - for line in ' '.join(map(str, args)).split('\n'): - print('::warning ::{}'.format(line)) + if isinstance(column, range) and column.start == column.stop + 1 and column.step == 1: + column = column.stop + if isinstance(column, int): + params.append(f'col={column}') + elif isinstance(column, range) and column.start < column.stop and column.step == 1: + params.append(f'col={column.start}') + params.append(f'endColumn={column.stop-1}') + + if title: + params.append(f'title={title}') + + prefix = f'::{type} {",".join(params)}::' + for line in ' '.join(map(str, msg)).split('\n'): + print(prefix + line) -def error(*args: Any) -> None: + +debug = functools.partial(_log_msg, type='debug') +notice = functools.partial(_log_msg, type='notice') +warning = functools.partial(_log_msg, type='warning') +error = functools.partial(_log_msg, type='error') + + +def info(*args: Any) -> None: + print('\033[1;37m', end='') for line in ' '.join(map(str, args)).split('\n'): - print('::error ::{}'.format(line)) + print(line) + print('\033[0m', end='', flush=True) def set_output(key: str, value: str) -> None: diff --git a/devtools/gha/api.py b/devtools/gha/api.py new file mode 100644 index 000000000..490d36e72 --- /dev/null +++ b/devtools/gha/api.py @@ -0,0 +1,38 @@ +from http.client import HTTPSConnection +import json +import os +import sys + +_token = os.environ.get('GITHUB_TOKEN') +if not _token: + import getpass + _token = getpass.getpass('GitHub token: ') + +repo = os.environ.get('GITHUB_REPOSITORY', 'evalf/nutils') + +host = 'api.github.com' +_conn = HTTPSConnection(host) + +def _request(method, url, *, desired_status=200): + _conn.request( + method, + url, + headers={ + 'Host': host, + 'User-Agent': 'Nutils devtools', + 'Accept': 'application/vnd.github+json', + 'Authorization': f'Bearer {_token}', + 'X-GitHub-Api-Version': '2022-11-28', + }, + ) + response = _conn.getresponse() + if response.status != desired_status: + raise RuntimeError(f'ERROR: {method} https://{host}{url} failed: {response.status} {response.reason}') + return response.read() + +def list_workflow_run_artifacts(run_id: str): + # TODO: implement pagination: https://docs.github.com/en/rest/using-the-rest-api/using-pagination-in-the-rest-api?apiVersion=2022-11-28 + return json.loads(_request('GET', f'/repos/{repo}/actions/runs/{run_id}/artifacts'))['artifacts'] + +def delete_artifact(artifact_id: str): + _request('DELETE', f'/repos/{repo}/actions/artifacts/{artifact_id}', desired_status=204) diff --git a/devtools/gha/coverage_report_xml.py b/devtools/gha/coverage_report_xml.py deleted file mode 100644 index 795bdb8c9..000000000 --- a/devtools/gha/coverage_report_xml.py +++ /dev/null @@ -1,55 +0,0 @@ -import sys -import re -import os.path -from typing import Sequence -from xml.etree import ElementTree -from pathlib import Path -from coverage import Coverage - -paths = [] -for path in sys.path: - try: - paths.append(str(Path(path).resolve()).lower()+os.path.sep) - except FileNotFoundError: - pass -paths = list(sorted(paths, key=len, reverse=True)) -unix_paths = tuple(p.replace('\\', '/') for p in paths) -packages = tuple(p.replace('/', '.') for p in unix_paths) - -dst = Path('coverage.xml') - -# Generate `coverage.xml` with absolute file and package names. -cov = Coverage() -cov.load() -cov.xml_report(outfile=str(dst)) - -# Load the report, remove the largest prefix in `packages` from attribute -# `name` of element `package`, if any, and similarly the largest prefix in -# `paths` from attribute `filename` of element `class` and from the content of -# element `source`. Matching prefixes is case insensitive for case insensitive -# file systems. - - -def remove_prefix(value: str, prefixes: Sequence[str]) -> str: - lvalue = value.lower() - for prefix in prefixes: - if lvalue.startswith(prefix): - return value[len(prefix):] - return value - - -root = ElementTree.parse(str(dst)) -for elem in root.iter('package'): - for package in packages: - name = elem.get('name') - if name: - elem.set('name', remove_prefix(name, packages)) - for elem in root.iter('class'): - filename = elem.get('filename') - if filename: - elem.set('filename', remove_prefix(filename, unix_paths)) - for elem in root.iter('source'): - text = elem.text - if text: - elem.text = remove_prefix(text, paths) -root.write('coverage.xml') diff --git a/devtools/gha/delete_coverage_artifacts.py b/devtools/gha/delete_coverage_artifacts.py new file mode 100644 index 000000000..30d9ff104 --- /dev/null +++ b/devtools/gha/delete_coverage_artifacts.py @@ -0,0 +1,12 @@ +from . import api +from .. import log +import os + +run_id = os.environ.get('GITHUB_RUN_ID') +if not run_id: + raise RuntimeError('ERROR: environment variable GITHUB_RUN_ID not set') + +for artifact in api.list_workflow_run_artifacts(run_id): + if artifact['name'].startswith('_coverage_'): + log.debug(f'deleting {artifact["name"]}') + api.delete_artifact(artifact['id']) diff --git a/devtools/gha/report_coverage.py b/devtools/gha/report_coverage.py new file mode 100644 index 000000000..4c681d691 --- /dev/null +++ b/devtools/gha/report_coverage.py @@ -0,0 +1,118 @@ +from .. import log +import array +import itertools +import json +import os +from pathlib import Path +import subprocess + +cov_dir = Path() / 'target' / 'coverage' + +# Load and merge coverage data. +coverage = {} +for part in cov_dir.glob('*.json'): + with part.open('r') as f: + part = json.load(f) + for file_name, part_file_coverage in part.items(): + coverage.setdefault(file_name, []).append(part_file_coverage) +coverage = {file_name: array.array('B', list(map(max, *file_coverage)) if len(file_coverage) > 1 else file_coverage[0]) for file_name, file_coverage in coverage.items()} + +# Generate lcov. +with (cov_dir / 'coverage.info').open('w') as f: + print('TN:unittest', file=f) + for file_name, file_coverage in sorted(coverage.items()): + print(f'SF:{file_name}', file=f) + print('FNF:0', file=f) + print('FNH:0', file=f) + print('BRF:0', file=f) + print('BRH:0', file=f) + for i, status in enumerate(file_coverage[1:], 1): + if status: + print(f'DA:{i},{status - 1}', file=f) + hit = sum(status == 2 for status in file_coverage) + found = sum(status != 0 for status in file_coverage) + print(f'LH:{hit}', file=f) + print(f'LF:{found}', file=f) + print('end_of_record', file=f) + +# If this is a PR, build patch coverage data. +patch_coverage = {} +if os.environ.get('GITHUB_EVENT_NAME', None) == 'pull_request': + base = os.environ.get('GITHUB_BASE_REF') + subprocess.run(['git', 'fetch', '--depth=1', 'origin', base], check=True, stdin=subprocess.DEVNULL) + patch = iter(subprocess.run(['git', 'diff', '-U0', f'origin/{base}', '--'], check=True, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, text=True).stdout.splitlines()) + for line in patch: + # Skip to a file with coverage. + if not line.startswith('+++ b/'): + continue + file_name = line[6:].rstrip() + if (file_coverage := coverage.get(file_name)) is None: + continue + # Copy the full coverage and mask out unchanged lines. + patch_coverage[file_name] = patch_file_coverage = array.array('B', file_coverage) + prev_offset = 0 + for line in patch: + if line.startswith('--- '): + break + if line.startswith('@@ '): + chunk = line.split(' ')[2] + assert chunk.startswith('+') + if ',' in chunk: + offset, count = map(int, chunk[1:].split(',')) + else: + offset = int(chunk[1:]) + count = 1 + for i in range(prev_offset, offset): + patch_file_coverage[i] = 0 + prev_offset = offset + count + for i in range(prev_offset, len(patch_file_coverage)): + patch_file_coverage[i] = 0 + + # Annotate lines without coverage. + for file_name, file_coverage in sorted(patch_coverage.items()): + i = 0 + while i < len(file_coverage): + j = i + if file_coverage[i] == 1: + while j + 1 < len(file_coverage) and file_coverage[j + 1] == 1: + j += 1 + if i == j: + log.warning(f'Line {i} of `{file_name}` is not covered by tests.', file=file_name, line=i, title='Line not covered') + else: + log.warning(f'Lines {i}–{j} of `{file_name}` are not covered by tests.', file=file_name, line=range(i, j+1), title='Lines not covered') + i = j + 1 + +# Generate summary. +header = ['Name', 'Stmts', 'Miss', 'Cover'] +align = ['<', '>', '>', '>'] +if patch_coverage: + header += ['Patch stmts', 'Patch miss', 'Patch cover'] + align += ['>'] * 3 +table = [] +def row_stats(*data): + hit = 0 + miss = 0 + for data in data: + hit += data.count(2) + miss += data.count(1) + total = hit + miss + percentage = 100 * hit / (hit + miss) if hit + miss else 100. + return [str(total), str(miss), f'{percentage:.1f}%'] +for file_name, file_coverage in sorted(coverage.items()): + row = [f'`{file_name}`'] + row_stats(file_coverage) + if (patch_file_coverage := patch_coverage.get(file_name)): + row += row_stats(patch_file_coverage) + elif patch_coverage: + row += [''] * 3 + table.append(row) +row = ['TOTAL'] + row_stats(*coverage.values()) +if patch_coverage: + row += row_stats(*patch_coverage.values()) +table.append(row) +with open(os.environ.get('GITHUB_STEP_SUMMARY', None) or cov_dir / 'summary.md', 'w') as f: + width = tuple(max(map(len, columns)) for columns in zip(header, *table)) + print('| ' + ' | '.join(f'{{:<{w}}}'.format(h) for w, h in zip(width, header)) + ' |', file=f) + print('| ' + ' | '.join(':' + '-' * (w - 1) if a == '<' else '-' * (w - 1) + ':' for a, w in zip(align, width)) + ' |', file=f) + fmt = '| ' + ' | '.join(f'{{:{a}{w}}}' for a, w in zip(align, width)) + ' |' + for row in table: + print(fmt.format(*row), file=f) diff --git a/devtools/gha/unittest.py b/devtools/gha/unittest.py new file mode 100644 index 000000000..9dd2a51d3 --- /dev/null +++ b/devtools/gha/unittest.py @@ -0,0 +1,61 @@ +import importlib +import inspect +import json +import os +from pathlib import Path +import sys +import unittest + +source = importlib.util.find_spec('nutils').origin +assert source.endswith(os.sep + '__init__.py') +source = source[:-11] +# Dictionary of file names to line coverage data. The line coverage data is a +# list of `int`s, one for each line, with the following meaning: +# +# 0: line contains no statements +# 1: line is not hit +# 2: line is hit +# +# The first entry of the line coverage data is unused; hence line 1 is entry 1 +# in the line coverage data. +coverage = {} + +if hasattr(sys, 'monitoring'): + + def start(code, _): + if isinstance(code.co_filename, str) and code.co_filename.startswith(source) and not sys.monitoring.get_local_events(sys.monitoring.COVERAGE_ID, code): + if (file_coverage := coverage.get(code.co_filename)) is None: + with open(code.co_filename, 'rb') as f: + nlines = sum(1 for _ in f) + coverage[code.co_filename] = file_coverage = [0] * (nlines + 1) + for _, _, l in code.co_lines(): + if l: + file_coverage[l] = 1 + sys.monitoring.set_local_events(sys.monitoring.COVERAGE_ID, code, sys.monitoring.events.LINE) + for obj in code.co_consts: + if inspect.iscode(obj): + start(obj, None) + return sys.monitoring.DISABLE + + def line(code, line_number): + coverage[code.co_filename][line_number] = 2 + return sys.monitoring.DISABLE + + sys.monitoring.register_callback(sys.monitoring.COVERAGE_ID, sys.monitoring.events.PY_START, start) + sys.monitoring.register_callback(sys.monitoring.COVERAGE_ID, sys.monitoring.events.LINE, line) + sys.monitoring.use_tool_id(sys.monitoring.COVERAGE_ID, 'test') + sys.monitoring.set_events(sys.monitoring.COVERAGE_ID, sys.monitoring.events.PY_START) + +loader = unittest.TestLoader() +suite = loader.discover('tests', top_level_dir='.') +runner = unittest.TextTestRunner(buffer=True) +result = runner.run(suite) + +coverage = {file_name[len(source) - 7:].replace('\\', '/'): file_coverage for file_name, file_coverage in coverage.items()} +cov_dir = (Path() / 'target' / 'coverage') +cov_dir.mkdir(parents=True, exist_ok=True) +cov_file = cov_dir / (os.environ.get('COVERAGE_ID', 'coverage') + '.json') +with cov_file.open('w') as f: + json.dump(coverage, f) + +sys.exit(0 if result.wasSuccessful() else 1) diff --git a/nutils/SI.py b/nutils/SI.py index 73ea67397..bfe4ec874 100644 --- a/nutils/SI.py +++ b/nutils/SI.py @@ -15,7 +15,7 @@ >>> from nutils import SI >>> v = SI.parse('7μN*5h/6g') -The Quantity constructor recognizes the multiplication (\*) and division (/) +The Quantity constructor recognizes the multiplication (\\*) and division (/) operators to separate factors. Every factor can be prefixed with a scale and suffixed with a power. The remainder must be either a unit, or else a unit with a metric prefix. diff --git a/nutils/_graph.py b/nutils/_graph.py index 705e81da3..e710c891d 100644 --- a/nutils/_graph.py +++ b/nutils/_graph.py @@ -71,7 +71,7 @@ def export_graphviz(self, *, fill_color: Optional[GraphvizColorCallback] = None, treelog.warning('graphviz failed for error code', status.returncode) graph = status.stdout if image_type == 'svg': - graph = re.sub(b'') graph = graph[:i] + clickHandler + graph[i:]