Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adding the list definition utilities, and doctests #56

Merged
merged 3 commits into from
Apr 30, 2024
Merged
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
4 changes: 4 additions & 0 deletions HISTORY.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
=======
History
=======
2024.4.30 -- Added utility for handling list definitions
* Add list_definition.py with parse_list()
* Updated makefile for doctests.

2024.4.26 -- Removed debug printing
* Some debug printing was accidentally left in the code.

Expand Down
12 changes: 8 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
MODULE := seamm_util
.PHONY: clean clean-test clean-pyc clean-build docs help
.PHONY: clean clean-test clean-pyc clean-build docs help test coverage
.DEFAULT_GOAL := help
define BROWSER_PYSCRIPT
import os, webbrowser, sys
Expand Down Expand Up @@ -56,7 +56,7 @@ format: ## reformat with with yapf and isort
black $(MODULE) tests

test: ## run tests quickly with the default Python
py.test
pytest --doctest-modules tests $(MODULE)

dependencies:
pur -r requirements_dev.txt
Expand All @@ -65,8 +65,12 @@ dependencies:
test-all: ## run tests on every Python version with tox
tox

coverage: ## check code coverage quickly with the default Python
coverage run --source $(MODULE) -m pytest
coverage: clean-test ## check code coverage quickly with the default Python
pytest -v --doctest-modules --cov=$(MODULE) --cov-report=html tests/ $(MODULE)
$(BROWSER) htmlcov/index.html

coverage-old:
coverage run --source $(MODULE) -m pytest --doctest-modules tests $(MODULE)
coverage report -m
coverage html
$(BROWSER) htmlcov/index.html
Expand Down
4 changes: 2 additions & 2 deletions seamm_util/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
# -*- coding: utf-8 -*-

"""
seamm_util
"""seamm_util
Utility functions for the SEAMM environment.
"""

Expand All @@ -14,6 +13,7 @@
from .elemental_data import element_data # noqa: F401
from .check_executable import check_executable # noqa: F401
from .dictionary import Dictionary # noqa: F401
from .list_definition import parse_list # noqa: F401
from .units import ureg, Q_, units_class, default_units # noqa: F401
from .include_open import Open # noqa: F401
from .include_open import splitext # noqa: F401
Expand Down
159 changes: 159 additions & 0 deletions seamm_util/list_definition.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
# -*- coding: utf-8 -*-

"""Tools for handling the shorthand definitions of lists in SEAMM.
"""


def parse_list(input, duplicates=True, sort=False, **kwargs):
"""Parse a SEAMM-style list of values, including ranges.

Parameters
----------
input : str
The list definition (see below).
duplicates : bool = True
Allows duplicate values in the list by default.
sort : bool = False
If False, the list is not sorted. True or "ascending" sorts into ascending order
while "descending" into reverse order. Only the first letter is tested, so any
abbreviation works.
kwargs : {str: any}
Optional dictionary of values of variables used in the list.

Returns
-------
list : [int or float]
The expanded list of values

The input list definition consists of any number of individual values or ranges,
separated by commas (','). Ranges are similar to those in Python *except* that the
'stop' value is included in the list if stop = start + n*step. Ranges are defined as
start:stop[:step]. If step is ommitted, it defaults to 1.

Examples
--------
A simple, commma delimited list is transformed into a python list:

>>> parse_list("1, 2, 4, 5")
[1, 2, 4, 5]

If any of the values is a float, the result is also a float

>>> parse_list("1, 2.0, 4, 5")
[1, 2.0, 4, 5]

This is a simple range:

>>> parse_list("1:6")
[1, 2, 3, 4, 5, 6]

Note that the stop value is include in the result. This is different than a python
range where the termination is <, not <=. This is more natural for users of SEAMM.
For example, scanning a dihedral angle from 0 to 180 degrees, most people will
expect 180 to be in the list.

Now using a step of 2:

>>> parse_list("1:6:2")
[1, 3, 5]

Note that the stop value is not included in the output in this case.

The step can be negative as long as stop < start:

>>> parse_list("6:1:-2")
[6, 4, 2]

Now we can put it all together:

>>> parse_list("1, 2.0, 4, 5, 1:6, 1:6:2")
[1, 2.0, 4, 5, 1, 2, 3, 4, 5, 6, 1, 3, 5]

Note that a value can appear multiple times in a simple list. This can be controlled
by setting duplicates = False:

>>> parse_list("1, 2.0, 4, 5, 1:6, 1:6:2", duplicates=False)
[1, 2.0, 4, 5, 3, 6]

You can also sort the results into ascending or descending order:

>>> parse_list("1, 2.0, 4, 5, 1:6, 1:6:2", sort="ascending")
[1, 1, 1, 2.0, 2, 3, 3, 4, 4, 5, 5, 5, 6]

and if you want to remove the duplicates too:

>>> parse_list("1, 2.0, 4, 5, 1:6, 1:6:2", duplicates=False, sort="descending")
[6, 5, 4, 3, 2.0, 1]

Where do variable fit? Any of the values in the input string can be variables as
as long as values are are given as keyword arguments:

>>> parse_list("first:last:incr", first=1, last=6, incr=0.5)
[1, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0, 4.5, 5.0, 5.5, 6.0]

It is often convenient if the variables are in a dictionary:

>>> values = {"first": 6, "last": 1, "incr":-0.5}
>>> parse_list("first:last:incr", **values)
[6, 5.5, 5.0, 4.5, 4.0, 3.5, 3.0, 2.5, 2.0, 1.5]
"""
_globals = {
"__builtins__": None,
}
result = []
for part in input.split(","):
if ":" in part:
# A range...
tmp = part.split(":")
if len(tmp) == 3:
start, stop, step = tmp
elif len(tmp) == 2:
start, stop = tmp
step = "1"
else:
raise RuntimeError(f"Badly formed range '{part}' in parse_list")

start = eval(start, _globals, kwargs)
stop = eval(stop, _globals, kwargs)
step = eval(step, _globals, kwargs)

# Sanity checks!
if step == 0:
raise ValueError("A step of zero is not allow in ranges!")
elif step < 0 and start < stop:
raise ValueError(f"If start < stop, the step must be positive: {step}")
elif step > 0 and start > stop:
raise ValueError(f"If stop < start, the step must be negative: {step}")

# Flip the sign of the test if counting down
sign = -1 if step < 0 else 1
value = start

# Add a very small number for roundoff in floats
while sign * (stop - value + stop / 1.0e8) >= 0:
if not duplicates and value in result:
pass
else:
result.append(value)
value += step
else:
value = eval(part, _globals, kwargs)
result.append(value)

if isinstance(sort, str):
if sort[0] == "d":
result.sort(reverse=True)
elif sort[0] == "a":
result.sort()
else:
raise ValueError("'sort' must be 'a...' or 'd...' or a bool")
elif sort:
result.sort()

return result


if __name__ == "__main__":
import doctest

result = doctest.testmod(verbose=False)
Loading