diff --git a/.gitignore b/.gitignore index 899fa46d..7d162730 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,12 @@ /test/ */__pycache__ __pycache__ +*.egg-info + +# Tex Files (only allowed in the tests folder) *.tex -*.egg-info \ No newline at end of file +!tests/**/*.tex + +# Temporary files from pytest +tests/tmp/ +!tests/.gitkeep diff --git a/.vscode/settings.json b/.vscode/settings.json index 97e171c4..af2911c6 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -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, diff --git a/Pipfile b/Pipfile index cc9fe33d..70624017 100644 --- a/Pipfile +++ b/Pipfile @@ -4,7 +4,6 @@ verify_ssl = true name = "pypi" [packages] -plum-dispatch = "~=2.3" [dev-packages] pylint = "~=3.0" diff --git a/Pipfile.lock b/Pipfile.lock index 37d8af23..94768b86 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "cbec9547e5137a6a3a0f7ba14ff98a24a53adb966549045956b3e506835e0bdf" + "sha256": "a5f83ecca60c4365a35a8eb10b1051286f4099f88ff14dab578be8367888ba0e" }, "pipfile-spec": 6, "requires": { @@ -15,57 +15,7 @@ } ] }, - "default": { - "beartype": { - "hashes": [ - "sha256:c22b21e1f785cfcf5c4d3d13070f532b6243a3ad67e68d2298ff08d539847dce", - "sha256:e911e1ae7de4bccd15745f7643609d8732f64de5c2fb844e89cbbed1c5a8d495" - ], - "markers": "python_full_version >= '3.8.0'", - "version": "==0.17.2" - }, - "markdown-it-py": { - "hashes": [ - "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", - "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb" - ], - "markers": "python_version >= '3.8'", - "version": "==3.0.0" - }, - "mdurl": { - "hashes": [ - "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", - "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba" - ], - "markers": "python_version >= '3.7'", - "version": "==0.1.2" - }, - "plum-dispatch": { - "hashes": [ - "sha256:96f519d416accf9a009117682f689114eb23e867bb6f977eed74ef85ef7fef9d", - "sha256:f49f00dfdf7ab0f16c9b85cc27cc5241ffb59aee02218bac671ec7c1ac65e139" - ], - "index": "pypi", - "markers": "python_version >= '3.8'", - "version": "==2.3.2" - }, - "pygments": { - "hashes": [ - "sha256:b27c2826c47d0f3219f29554824c30c5e8945175d888647acd804ddd04af846c", - "sha256:da46cec9fd2de5be3a8a784f434e4c4ab670b4ff54d605c4c2717e9d49c4c367" - ], - "markers": "python_version >= '3.7'", - "version": "==2.17.2" - }, - "rich": { - "hashes": [ - "sha256:4edbae314f59eb482f54e9e30bf00d33350aaa94f4bfcd4e9e3110e64d0d7222", - "sha256:9be308cb1fe2f1f57d67ce99e95af38a1e2bc71ad9813b0e247cf7ffbcc3a432" - ], - "markers": "python_full_version >= '3.7.0'", - "version": "==13.7.1" - } - }, + "default": {}, "develop": { "astroid": { "hashes": [ diff --git a/pyproject.toml b/pyproject.toml index 2d95a481..ffe4396f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" @@ -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/" diff --git a/src/api/parsers.py b/src/api/parsers.py index 87bbd921..c54f6511 100644 --- a/src/api/parsers.py +++ b/src/api/parsers.py @@ -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: @@ -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)) diff --git a/src/api/res.py b/src/api/res.py index 76642471..45ca71cd 100644 --- a/src/api/res.py +++ b/src/api/res.py @@ -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 @@ -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) @@ -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) @@ -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 diff --git a/src/application/error_messages.py b/src/application/error_messages.py index 94f05490..d4c019f4 100644 --- a/src/application/error_messages.py +++ b/src/application/error_messages.py @@ -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" @@ -35,15 +39,18 @@ 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=)`." ) 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." @@ -51,7 +58,7 @@ "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 " diff --git a/tests/integration/fixtures/eiffeltower.tex b/tests/integration/fixtures/eiffeltower.tex new file mode 100644 index 00000000..58224928 --- /dev/null +++ b/tests/integration/fixtures/eiffeltower.tex @@ -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}}}}}}}}} +} \ No newline at end of file diff --git a/tests/integration/whole_workflow_test.py b/tests/integration/whole_workflow_test.py new file mode 100644 index 00000000..bd339165 --- /dev/null +++ b/tests/integration/whole_workflow_test.py @@ -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() diff --git a/tests/playground.py b/tests/playground.py index 2f28ad52..6e324032 100644 --- a/tests/playground.py +++ b/tests/playground.py @@ -32,37 +32,35 @@ # wiz.res("", 42.0).print() # -> Error: "name must not be empty" -wiz.res("a911", 1.05, r"\mm\s\per\N\kg") +wiz.res("a911", 1.05, unit=r"\mm\s\per\N\kg") # wiz.res("a911", "1.052", 0.25, r"\mm\s\per\N\kg") -wiz.res("1 b", 1.0, 0.01, r"\per\mm\cubed") +wiz.res("1 b", 1.0, 0.01, unit=r"\per\mm\cubed") # wiz.config(decimal_places=-1, sigfigs_fallback=3) wiz.res("c big", 1.0, (0.01, "systematic"), r"\mm") -wiz.res("d", 1.0e10, [(0.01e10, "systematic"), (0.0294999e10, "stat")], r"\mm\per\second\squared") -wiz.res("e", "1.0", r"\mm") +wiz.res("d", 1.0e10, [(0.01e10, "sysyeah"), (0.0294999e10, "statyeah")], r"\mm\per\second^2") +# wiz.res("e", "1.0", r"\mm") # -> except error message that maybe we have forgotten to put `unit=` + wiz.res("f", "1.0e1", 25e-1) wiz.res("g", 42) -wiz.res("h", 42, 13.0, 24.0) -wiz.res("h&", 42, 13.0, 24.0) -wiz.res("i", Decimal("42.0e-30"), Decimal("0.1e-31"), r"\m") -wiz.res("i", Decimal("42.0e-30"), Decimal("0.1e-31"), Decimal("0.05e-31"), r"\m\per\s\squared") -wiz.res("j", 0.009, None, None, 2) -# wiz.res("k", 1.55, 0.0, r"\tesla") # -> uncertainty must be positive - -# wiz.res("k", 3, 1, r"\tesla") # -> plum: Could not be resolved -# TODO: Find out if one can adjust the plum.resolver.NotFoundLookupError such that -# we can give better hints, e.g. "you cannot pass in value and uncertainty as integers" - -# wiz.res("g", 1.0, sys=0.01, stat=0.02, unit=r"\mm").print() -# g: (1.0 ± 0.01 sys ± 0.02 stat) \mm -# TODO: Why does this not work? -# -> This fix might help: https://github.com/beartype/plum/issues/40#issuecomment-1836613508 - -# The following wont' work as we can't have positional arguments (here: unit) -# after keyword arguments (here: uncert) -# wiz.res("d", 1.0, uncert=[(0.01, "systematic"), (0.02, "stat")], r"\mm").print() +wiz.res("h", 42, sys=13.0, stat=24.0) +wiz.res("h&", 42, sys=13.0, stat=24.0) + +wiz.res("i", Decimal("42.0e-30"), Decimal("0.1e-31"), unit=r"\m") +wiz.res( + "i", + Decimal("42.0e-30"), + sys=Decimal("0.1e-31"), + stat=Decimal("0.05e-31"), + unit=r"\m\per\s\squared", +) +wiz.res("j", 0.009, None, "", 2) # really bad, but this is valid +# wiz.res("k", 1.55, 0.0, unit=r"\tesla") # -> uncertainty must be positive +wiz.res("k", 3, 1, r"\tesla") # integers work as well, yeah +wiz.res("l", 1.0, sys=0.01, stat=0.02, unit=r"\mm").print() +wiz.res("m", 1.0, uncert=[(0.01, "systematic"), (0.02, "stat")], unit=r"\mm").print() # wiz.table( # "name", @@ -75,6 +73,8 @@ # horizontal = True, # ) +wiz.res("Tour Eiffel Height", "330.3141516", "0.5", r"\m") +wiz.res("g Another Test", 9.81, 0.78, unit=r"\m/\s^2") ############################# # Export