diff --git a/README.rst b/README.rst index 43132d9..4460d3e 100644 --- a/README.rst +++ b/README.rst @@ -1,9 +1,9 @@ pyelsepa ======== -This is a Python wrapper for the Fortran code ELSEPA, which does a "Dirac partial-wave calculation of elastic scattering of electrons and positrons by atoms, positive ions and molecules". ELSEPA is described in Salvat, Jablonski and Powell (2005) [1]_ (which is, sadly, behind a paywall). The Fortran source can be downloaded at Elsevier's Computer Physics Communications Program Library `adus_v1_0.tar.gz`_ under a researchers attribution type of license. +This is a Python wrapper for the Fortran code ELSEPA, which does a "Dirac partial-wave calculation of elastic scattering of electrons and positrons by atoms, positive ions and molecules". ELSEPA is described in Salvat, Jablonski and Powell (2005) [1]_ (which is, sadly, behind a paywall). The original Fortran source can be downloaded at Elsevier's Computer Physics Communications Program Library `adus_v1_0.tar.gz`_ under a researchers attribution type of license. -This Python wrapper uses `Docker`_ to wrap the Fortran code in a clean environment. +This Python wrapper relies on a slightly modified version of ELSEPA, which can be found on `github`_. The difference is in the use of an environment variable that allows us to run multiple instances of ELSEPA in parallel. Requirements ~~~~~~~~~~~~ @@ -11,21 +11,7 @@ Requirements * `Python 3`_ * `NumPy`_ * `Pint`_ -* `Docker`_ - -ELSEPA Docker image -~~~~~~~~~~~~~~~~~~~ - -To use the dockerized version of Elsepa, first make sure you have the *latest version* of `Docker`_ installed. If you are working in (a Debian/Ubuntu flavoured) GNU Linux, please follow the instructions in the `Docker installation manual`_. - -To build the image, you should have downloaded the file `adus_v1_0.tar.gz`_, and placed it in the `docker` directory. Then from the `docker` directory (containing `Dockerfile`) run:: - - docker build -t elsepa . - -If you want to be sure that the container works, start an interactive session and run the :math:`H_2O` example:: - - docker run -i -t elsepa - ./elscatm < h2o.in +* `Modified ELSEPA`_ Installing ~~~~~~~~~~ @@ -38,6 +24,8 @@ or install it with user privileges:: pip install . --user +By default, it is assumed that the ELSEPA binaries are located in ``/opt/elsepa`` and that the data is located in ``/opt/elsepa/data``. + Citation ~~~~~~~~ @@ -46,6 +34,6 @@ Citation .. _`Python 3`: http://www.python.org/ .. _`NumPy`: http://www.numpy.org/ .. _`Pint`: https://pint.readthedocs.io -.. _`Docker`: http://www.docker.com/ -.. _`Docker installation manual`: https://docs.docker.com/engine/installation/ +.. _`github`: https://github.com/eScatter/elsepa +.. _`Modified ELSEPA`: https://github.com/eScatter/elsepa .. _`adus_v1_0.tar.gz`: http://www.cpc.cs.qub.ac.uk/summaries/ADUS_v1_0.html diff --git a/docker/Dockerfile b/docker/Dockerfile deleted file mode 100644 index c032eca..0000000 --- a/docker/Dockerfile +++ /dev/null @@ -1,15 +0,0 @@ -FROM debian:8 -MAINTAINER Johan Hidding - -# fortran compiler -RUN apt-get update && apt-get install --no-install-recommends -y \ - gfortran - -# unpack elsepa -COPY adus_v1_0.tar.gz /tmp -RUN tar xf /tmp/adus_v1_0.tar.gz -C opt - -# compile -WORKDIR /opt/elsepa -RUN f77 -O3 elscata.f -o elscata && f77 -O3 elscatm.f -o elscatm - diff --git a/docker/README.md b/docker/README.md deleted file mode 100644 index 11f44bc..0000000 --- a/docker/README.md +++ /dev/null @@ -1,14 +0,0 @@ -ELSEPA Docker image -=================== - -To use the dockerized version of Elsepa, first make sure you have the *latest version* of [Docker](http://docker.com) installed. - -To build the image, you should have downloaded the file [`adus_v1_0.tar.gz`](http://www.cpc.cs.qub.ac.uk/summaries/ADUS_v1_0.html), and placed it in this directory. Then run:: - - docker build -t elsepa . - -If you want to be sure that the container works, start an interactive session and run the H2O example:: - - docker run -i -t elsepa - ./elscatm < h2o.in - diff --git a/elsepa/executable.py b/elsepa/executable.py deleted file mode 100644 index db63819..0000000 --- a/elsepa/executable.py +++ /dev/null @@ -1,244 +0,0 @@ -import subprocess -import os -import posixpath -import sys - -try: - import docker - import json - import tarfile - import io - import time - -except ImportError: - has_docker = False -else: - has_docker = True - - -class SimpleExecutable(object): - """Wrapper for external executable. - - .. py::attribute:: name - (string) The name of the program (human readable). - - .. py::attribute:: path - (string) The path pointing to the executable. - - .. py::attribute:: description - (None or string) Describing the function of the executable, - possibly with input and output specified. - - .. py::attribute:: parameters - (None or function) Should be a function taking one argument, - returning a list of strings. This list is then passed as - command-line arguments to the executable. This function should - be able to handle `None` as an argument. - """ - def __init__(self, name, description, path, - working_dir=None, parameters=None): - self.name = name - self.path = path - self.description = description - self.working_dir = working_dir - self.parameters = parameters - - def run(self, args_obj=None, **kwargs): - """Call `subprocess.run`. - - :param args_obj: - Object containing information for arguments. This is passed - through the :py:attribute:`parameters` function attribute to - generate the list of command-line arguments. - :type args_obj: Any - - :param **kwargs: - Keyword arguments are passed to `subprocess.run`. - - :return: - CompletedProcess object. - """ - if self.working_dir: - orig_wd = os.getcwd() - os.chdir(self.working_dir) - - args = [self.path] - if self.parameters: - args.extend(self.parameters(args_obj)) - - result = subprocess.run(args, **kwargs) - - if self.working_dir: - os.chdir(orig_wd) - - return result - - -def build_image(client: docker.APIClient, path: str, name: str): - """Build the Docker image as per Dockerfile present in . - If the docker image with given name is newer than the Dockerfile, - nothing is done. - - :param client: - Docker client - :param path: - Location of Dockerfile - :param name: - Name of the image - """ - assert os.path.exists(os.path.join(path, 'Dockerfile')) - time = os.stat(os.path.join(path, 'Dockerfile')).st_mtime - - il = client.images(name=name) - if len(il) == 0 or il[0]['Created'] < time: - response = client.build(path, tag=name, rm=True) - for json_bytes in response: - line = json.loads(json_bytes.decode())['stream'] - print(line, end='', file=sys.stderr, flush=True) - - -class Archive(object): - """Easy interface to `tarfile`. - - We use buffered `tar` files to communicate with Docker - containers. This class provides an easy way to create - `tar` buffers on the fly, or read from them. - - Methods in this class can be chained JS style.""" - def __init__(self, mode, data=None): - self.file = io.BytesIO(data) - self.tar = tarfile.open(mode=mode, fileobj=self.file) - - def add_text_file(self, filename: str, text: str, encoding='utf-8'): - """Add the contents of `text` to a new entry in the `tar` - file. - - :return: - self - """ - b = text.encode(encoding) - f = io.BytesIO(b) - info = tarfile.TarInfo(filename) - info.size = len(b) - info.type = tarfile.REGTYPE - info.mtime = time.time() - - self.tar.addfile(info, fileobj=f) - return self - - def get_text_file(self, filename: str, encoding='utf-8') -> str: - """Read the contents of a file in the archive. - - :return: - contents of file in string. - """ - f = self.tar.extractfile(filename) - if f: - return f.read().decode(encoding) - return None - - def close(self): - self.tar.close() - return self - - def __iter__(self): - return iter(self.tar.getmembers()) - - @property - def buffer(self): - return self.file.getvalue() - - -class DockerContainer(object): - """Easy interface to Docker API. - - This class encapsulates a part of the Docker API, with the goal - of making it easier to start a container, add some files, run a - few scripts, read the output, and then remove the container - again. - - The object is a context manager for the created Docker container, - in that it starts the container upon entry and exterminates the - same container upon exit. - """ - - client = docker.APIClient(version='auto') - - def __init__(self, image, working_dir=None): - self.image = image - self.working_dir = working_dir - - container = self.client.create_container( - image=image, detach=True, stdin_open=True, - working_dir=working_dir) - self.container_id = container['Id'] - - def put_archive(self, archive, path="."): - """Put the contents of an archive into the Docker container. - - :param archive: - The `Archive` instance that contains the files that need - to be injected. - :type archive: Archive - - :param path: - Where to extract the archive within the container. - :type path: str - """ - if self.working_dir is not None: - path = posixpath.join(self.working_dir, path) - - self.client.put_archive( - self.container_id, path, archive.buffer) - - def get_archive(self, path): - """Get a file or directory from the container and make it into - an `Archive` object.""" - if self.working_dir is not None and not posixpath.isabs(path): - path = posixpath.join(self.working_dir, path) - - strm, stat = self.client.get_archive( - self.container_id, path) - - return Archive('r', strm.read()) - - def run(self, cmd, **kwargs): - """Run a command. - - :param cmd: - Command to be run and arguments as a list. - :type cmd: List[str] - - :param kwargs: - Forwarded to Docker-py `exec_create` function call. - - :return: - Output of command. - :rtype: bytes - """ - exe = self.client.exec_create( - container=self.container_id, - cmd=cmd, **kwargs) - - return self.client.exec_start(exec_id=exe['Id']) - - def sh(self, *cmds): - """Run a command with `sh -c`.""" - return self.run(['sh', '-c', ' && '.join(cmds)]) - - def start(self): - self.client.start(container=self.container_id) - - def kill(self): - self.client.kill(self.container_id) - - def remove(self, force=False): - self.client.remove_container(self.container_id, force=force) - - def __enter__(self): - self.start() - return self - - def __exit__(self, exc_type, exc_value, exc_st): - self.kill() - self.remove() diff --git a/elsepa/run.py b/elsepa/run.py index 0f8a3f5..f9325f2 100644 --- a/elsepa/run.py +++ b/elsepa/run.py @@ -1,32 +1,51 @@ from elsepa.generate_input import (generate_elscata_input, Settings) from elsepa.parse_output import (elsepa_output_parsers) -from elsepa.executable import (DockerContainer, Archive) import re - - -def elscata(settings: Settings): - with DockerContainer('elsepa', working_dir='/opt/elsepa') as elsepa: - elsepa.put_archive( - Archive('w') - .add_text_file('input.dat', generate_elscata_input(settings)) - .close()) - - elsepa.sh('./elscata < input.dat', - 'mkdir result && mv *.dat result') - - result_tar = elsepa.get_archive('result') - result = {} - - for info in result_tar: - if not info.isfile(): - continue - - name = re.match("result/(.*)\\.dat", info.name).group(1) +import os +import glob +import shutil +import subprocess +import tempfile + +# Run elscata. +# By default, the ELSEPA path is found from $PATH and the data folder +# is found from the ELSEPA_DATA environment variable. +# They may be overridden by providing the last two parameters. +def elscata(settings: Settings, elsepa_dir=None, elsepa_data_dir=None): + # Get path to the elscata binary and data directory. + elscata_binary = shutil.which('elscata') if elsepa_dir is None \ + else os.path.join(elsepa_dir, 'elscata') + elsepa_data_dir = os.environ.get('ELSEPA_DATA') if elsepa_data_dir is None \ + else elsepa_data_dir + + # Check if they are actually accessible + if not os.path.isfile(elscata_binary): + raise FileNotFoundError('Unable to find the elscata binary') + if not os.path.isfile(os.path.join(elsepa_data_dir, 'z_001.den')): + raise FileNotFoundError('Unable to find the ELSEPA data directory') + + result = {} + + with tempfile.TemporaryDirectory() as elsepa_output_dir: + # Run elscata + elscata_environment = os.environ.copy() + elscata_environment['ELSEPA_DATA'] = elsepa_data_dir + + subprocess.run(elscata_binary, + input=bytes(generate_elscata_input(settings), 'utf-8'), + stdout=subprocess.DEVNULL, + cwd=elsepa_output_dir, + env=elscata_environment) + + # Collect output + for fn in glob.glob(os.path.join(elsepa_output_dir, '*.dat')): + name = re.match("(.*)\\.dat", os.path.basename(fn)).group(1) parser = elsepa_output_parsers[name] if parser: - lines = result_tar.get_text_file(info).split('\n') + with open(fn, "r") as f: + lines = f.readlines() result[name] = parser(lines) return result diff --git a/setup.py b/setup.py index 6e43fe4..247b047 100644 --- a/setup.py +++ b/setup.py @@ -12,7 +12,7 @@ setup( name='pyelsepa', - version='0.1.1', + version='0.2.0', long_description=long_description, description='Wrapper for ELSEPA: Dirac partial-wave calculation' ' of elastic scattering of electrons and positrons by atoms, positive' @@ -31,7 +31,7 @@ 'Programming Language :: Python :: 3', 'Topic :: Scientific/Engineering :: Physics'], install_requires=[ - 'pint==0.8.1', 'numpy==1.13.0', 'docker==2.4.0', 'cslib', 'noodles[prov,numpy]==0.2.3'], + 'pint==0.8.1', 'numpy==1.13.0', 'cslib'], extras_require={ 'test': ['pytest'] }, diff --git a/test/test_docker.py b/test/test_docker.py deleted file mode 100644 index a8a38d3..0000000 --- a/test/test_docker.py +++ /dev/null @@ -1,51 +0,0 @@ -from elsepa.executable import (DockerContainer, Archive) - - -awk_program = """# usage: awk -f rot13.awk -BEGIN { - for(i=0; i < 256; i++) { - amap[sprintf("%c", i)] = i - } - for(l=amap["a"]; l <= amap["z"]; l++) { - rot13[l] = sprintf("%c", (((l-amap["a"])+13) % 26 ) + amap["a"]) - } - FS = "" -} -{ - o = "" - for(i=1; i <= NF; i++) { - if ( amap[tolower($i)] in rot13 ) { - c = rot13[amap[tolower($i)]] - if ( tolower($i) != $i ) c = toupper(c) - o = o c - } else { - o = o $i - } - } - print o -}""" - -message = "Vf gurer nalobql BHG gurer?" -decoded = "Is there anybody OUT there?" - -def test_docker_hello_world(): - with DockerContainer('busybox') as c: - m = c.run(['/bin/sh', '-c', "echo 'Hello, World!'"]) - assert m.decode().strip() == "Hello, World!" - - -def test_docker_rot13(): - with DockerContainer('busybox') as c: - c.put_archive( - Archive('w') - .add_text_file('rot13.awk', awk_program) - .add_text_file('input.txt', message) - .close()) - - c.run([ - '/bin/sh', '-c', - "/bin/awk -f 'rot13.awk' < input.txt > output.txt"]) - - m = c.get_archive('output.txt').get_text_file('output.txt') - - assert m.strip() == decoded