Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
/test/
*/__pycache__
__pycache__
*.egg-info

# Tex Files (only allowed in the tests folder)
*.tex
*.egg-info
!tests/**/*.tex

# Temporary files from pytest
tests/tmp/
!tests/.gitkeep
3 changes: 2 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@
"testExplorer.codeLens": true,
"testExplorer.errorDecorationHover": true,
"python.testing.pytestArgs": [
"."
".",
"--basetemp=${workspaceFolder}/tests/tmp/"
],
"python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true,
Expand Down
1 change: 0 additions & 1 deletion Pipfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ verify_ssl = true
name = "pypi"

[packages]
plum-dispatch = "~=2.3"

[dev-packages]
pylint = "~=3.0"
Expand Down
54 changes: 2 additions & 52 deletions Pipfile.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 4 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,6 @@ classifiers = [
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
]
dependencies = [
"plum-dispatch ~= 2.3"
]

[project.urls]
Homepage = "https://github.com/paul019/ResultWizard"
Expand All @@ -71,3 +68,7 @@ max-module-lines = 500

[tool.black]
line-length = 100

# See: https://docs.pytest.org/en/stable/reference/reference.html#confval-pythonpath
[tool.pytest.ini_options]
pythonpath = "src/"
9 changes: 7 additions & 2 deletions src/api/parsers.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ def check_if_number_string(value: str) -> None:
try:
float(value)
except ValueError as exc:
raise ValueError(error_messages.STRING_MUST_BE_NUMBER.format(value)) from exc
raise ValueError(error_messages.STRING_MUST_BE_NUMBER.format(value=value)) from exc


def parse_name(name: str) -> str:
Expand Down Expand Up @@ -204,7 +204,12 @@ def _parse_uncertainty_value(value: Union[float, int, str, Decimal]) -> Value:
"""Parses the value of an uncertainty."""

if isinstance(value, str):
check_if_number_string(value)
try:
check_if_number_string(value)
except Exception as exc:
msg = error_messages.STRING_MUST_BE_NUMBER.format(value=value)
msg += ". " + error_messages.UNIT_NOT_PASSED_AS_KEYWORD_ARGUMENT
raise ValueError(msg) from exc
return_value = parse_exact_value(value)
else:
return_value = Value(Decimal(value))
Expand Down
95 changes: 27 additions & 68 deletions src/api/res.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
from decimal import Decimal
from typing import Union, List, Tuple
from plum import dispatch, overload

from api.printable_result import PrintableResult
from api import parsers
Expand All @@ -16,79 +15,35 @@
import api.config as c # pylint: disable=wrong-import-position,ungrouped-imports


@overload
def res(
name: str,
value: Union[float, int, str, Decimal],
unit: str = "",
sigfigs: Union[int, None] = None,
decimal_places: Union[int, None] = None,
) -> PrintableResult:
return res(name, value, [], unit, sigfigs, decimal_places)


@overload
# pylint: disable-next=too-many-arguments, too-many-locals
def res(
name: str,
value: Union[float, int, str, Decimal],
uncert: Union[
float,
int,
str,
Decimal,
Tuple[Union[float, int, str, Decimal], str],
List[Union[float, int, str, Decimal, Tuple[Union[float, int, str, Decimal], str]]],
None,
] = None,
sigfigs: Union[int, None] = None,
decimal_places: Union[int, None] = None,
) -> PrintableResult:
return res(name, value, uncert, "", sigfigs, decimal_places)


@overload
def res(
name: str,
value: Union[float, int, str, Decimal],
sigfigs: Union[int, None] = None,
decimal_places: Union[int, None] = None,
) -> PrintableResult:
return res(name, value, [], "", sigfigs, decimal_places)


@overload
# pylint: disable=too-many-arguments
def res(
name: str,
value: Union[float, int, str, Decimal],
sys: Union[float, Decimal],
stat: Union[float, Decimal],
unit: str = "",
sys: Union[float, int, str, Decimal, None] = None,
stat: Union[float, int, str, Decimal, None] = None,
sigfigs: Union[int, None] = None,
decimal_places: Union[int, None] = None,
) -> PrintableResult:
return res(name, value, [(sys, "sys"), (stat, "stat")], unit, sigfigs, decimal_places)
):
"""
Declares your result. Give it a name and a value. You may also optionally provide
uncertainties (via `uncert` or `sys`/`stat`) and a unit in `siunitx` format.

You may additionally specify the number of significant figures or decimal places
to round this specific result to, irrespective of your global configuration.

@overload
# pylint: disable=too-many-arguments
def res(
name: str,
value: Union[float, int, str, Decimal],
uncert: Union[
float,
str,
Decimal,
Tuple[Union[float, int, str, Decimal], str],
List[Union[float, int, str, Decimal, Tuple[Union[float, int, str, Decimal], str]]],
None,
] = None,
unit: str = "",
sigfigs: Union[int, None] = None,
decimal_places: Union[int, None] = None,
) -> PrintableResult:
if uncert is None:
uncert = []

TODO: provide a link to the docs for more information and examples.
"""
# Verify user input
if sigfigs is not None and decimal_places is not None:
raise ValueError(error_messages.SIGFIGS_AND_DECIMAL_PLACES_AT_SAME_TIME)

Expand All @@ -98,6 +53,20 @@ def res(
if decimal_places is not None and isinstance(value, str):
raise ValueError(error_messages.DECIMAL_PLACES_AND_EXACT_VALUE_AT_SAME_TIME)

sys_or_stat_specified = sys is not None or stat is not None
if uncert is not None and sys_or_stat_specified:
raise ValueError(error_messages.UNCERT_AND_SYS_STAT_AT_SAME_TIME)

if sys_or_stat_specified:
uncert = []
if sys is not None:
uncert.append((sys, "sys"))
if stat is not None:
uncert.append((stat, "stat"))

if uncert is None:
uncert = []

# Parse user input
name_res = parsers.parse_name(name)
value_res = parsers.parse_value(value)
Expand All @@ -124,13 +93,3 @@ def res(
_export(immediate_export_path, print_completed=False)

return printable_result


# Hack for method "overloading" in Python
# see https://beartype.github.io/plum/integration.html
# This is a good writeup: https://stackoverflow.com/a/29091980/
@dispatch
def res(*args, **kwargs) -> object: # pylint: disable=unused-argument
# This method only scans for all `overload`-decorated methods
# and properly adds them as Plum methods.
pass
17 changes: 12 additions & 5 deletions src/application/error_messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,15 @@
DECIMAL_PLACES_AND_EXACT_VALUE_AT_SAME_TIME = (
"You can't set decimal places and supply an exact value. Please do one or the other."
)
UNCERT_AND_SYS_STAT_AT_SAME_TIME = (
"You can't set uncertainties and systematic/statistical uncertainties at the same time. "
"Please provide either the `uncert` param or the `sys`/`stat` params."
)

# Parser error messages (generic)
STRING_MUST_BE_NUMBER = "String value must be a valid number, not {value}"
FIELD_MUST_BE_STRING = "{field} must be a string, not {type}}"
FIELD_MUST_BE_INT = "{field} must be an int, not {type}}"
FIELD_MUST_BE_STRING = "{field} must be a string, not {type}"
FIELD_MUST_BE_INT = "{field} must be an int, not {type}"
FIELD_MUST_NOT_BE_EMPTY = "{field} must not be empty"
FIELD_MUST_BE_POSITIVE = "{field} must be positive"
FIELD_MUST_BE_NON_NEGATIVE = "{field} must be non-negative"
Expand All @@ -35,23 +39,26 @@
UNCERTAINTIES_MUST_BE_TUPLES_OR = (
"Each uncertainty must be a tuple or a float/int/Decimal/str, not {type}"
)
UNIT_NOT_PASSED_AS_KEYWORD_ARGUMENT = (
"Could it be the case you provided a unit but forgot `unit=` in front of it?"
)

# Helpers:
# Helpers
PRECISION_TOO_LOW = (
"Your precision is set too low to be able to process the given value without any loss of "
"precision. Set a higher precision via: `wiz.config_init (precision=<a-high-enough-number>)`."
)
NUMBER_TO_WORD_TOO_HIGH = "For variable names, only use numbers between 0 and 999. Got {number}."

# Runtime errors:
# Runtime errors
SHORT_RESULT_IS_NONE = "Short result is None, but there should be at least two uncertainties."
INTERNAL_ROUNDER_HIERARCHY_ERROR = "Internal rounder hierarchy error. Please report this bug."
INTERNAL_MIN_EXPONENT_ERROR = "Internal min_exponent not set error. Please report this bug."
ROUND_TO_NEGATIVE_DECIMAL_PLACES = (
"Internal rounding to negative decimal places. Please report this bug."
)

# Warnings:
# Warnings
INVALID_CHARS_IGNORED = "Invalid characters in name were ignored: {chars}"
NUM_OF_DECIMAL_PLACES_TOO_LOW = (
"Warning: At least one of the specified values is out of range of the specified "
Expand Down
15 changes: 15 additions & 0 deletions tests/integration/fixtures/eiffeltower.tex
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
\newcommand*{\resultTourEiffelHeight}[1][]{
\ifthenelse{\equal{#1}{}}{
\qty{330.4 \pm 0.5}{\m}
}{\ifthenelse{\equal{#1}{value}}{
\num{330.4}
}{\ifthenelse{\equal{#1}{withoutError}}{
\qty{330.4}{\m}
}{\ifthenelse{\equal{#1}{error}}{
\qty{0.5}{\m}
}{\ifthenelse{\equal{#1}{unit}}{
\unit{\m}
}{\ifthenelse{\equal{#1}{withoutUnit}}{
\num{330.4 \pm 0.5}
}{\scriptsize{\textbf{Use one of these keywords (or no keyword at all): \texttt{value}, \texttt{withoutError}, \texttt{error}, \texttt{unit}, \texttt{withoutUnit}}}}}}}}}
}
37 changes: 37 additions & 0 deletions tests/integration/whole_workflow_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import re
from pathlib import Path
import pytest

import resultwizard as wiz


def eiffeltower():
wiz.res("Tour Eiffel Height", 330.362019, 0.5, r"\m")


class TestWholeWorkflow:

@pytest.fixture
def output_file(self, tmp_path) -> Path:
directory = tmp_path / "whole_workflow"
directory.mkdir()
return directory / "results.tex"

@pytest.mark.parametrize("res_callback", [eiffeltower])
def test_whole_workflow(self, output_file, res_callback):
res_callback()
wiz.export(output_file.as_posix())

# Actual exported text
actual_text = output_file.read_text()
pattern = r"\\newcommand\*{.*}"
matches = re.findall(pattern, actual_text, re.DOTALL)
assert len(matches) == 1
newcommand = matches[0]

# Expected text
expected_file = Path("tests/integration/fixtures") / f"{res_callback.__name__}.tex"
expected_text = expected_file.read_text()

# Compare
assert newcommand.split() == expected_text.split()
Loading