pacer-client 1.1.0rc1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2018-2026, Alliance for Energy Innovation, LLC, and other contributors. All rights reserved.
2
+
3
+ Redistribution and use in source and binary forms, with or without modification, are permitted provided that the
4
+ following conditions are met:
5
+
6
+ (1) Redistributions of source code must retain the above copyright notice, this list of conditions and the following
7
+ disclaimer.
8
+
9
+ (2) Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following
10
+ disclaimer in the documentation and/or other materials provided with the distribution.
11
+
12
+ (3) Neither the name of the copyright holder nor the names of any contributors may be used to endorse or promote products
13
+ derived from this software without specific prior written permission from the respective party.
14
+
15
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDER(S) AND ANY CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
16
+ INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
17
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER(S), ANY CONTRIBUTORS, THE UNITED STATES GOVERNMENT, OR THE UNITED
18
+ STATES DEPARTMENT OF ENERGY, NOR ANY OF THEIR EMPLOYEES, BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
19
+ EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF
20
+ USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
21
+ STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
22
+ ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,99 @@
1
+ Metadata-Version: 2.4
2
+ Name: pacer-client
3
+ Version: 1.1.0rc1
4
+ Summary: A standalone client for the NLR's Alfalfa application
5
+ License: LICENSE.txt
6
+ License-File: LICENSE.txt
7
+ Author: Kyle Benne
8
+ Author-email: kyle.benne@nlr.gov
9
+ Requires-Python: >=3.10
10
+ Classifier: License :: Other/Proprietary License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Programming Language :: Python :: 3.14
17
+ Requires-Dist: requests-toolbelt (>=1.0,<1.1)
18
+ Description-Content-Type: text/markdown
19
+
20
+ # Pacer Client
21
+
22
+ The purpose of this repository is to provide a standalone client for use with the Alfalfa application. It additionally includes a Historian to quickly/easily enable saving of results from Alfalfa simulations.
23
+
24
+ ## Usage
25
+
26
+ This repo is packaged and hosted on [PyPI here](https://pypi.org/project/pacer-client/).
27
+
28
+ ```bash
29
+ pip install pacer-client
30
+ ```
31
+
32
+ ```python
33
+ from pacer_client.pacer_client import PacerClient
34
+
35
+ client = PacerClient("http://localhost")
36
+ ```
37
+
38
+ > **Renaming from `alfalfa-client`:** this project was renamed from `alfalfa-client`/`AlfalfaClient` to
39
+ > `pacer-client`/`PacerClient`. During the transition, both names keep working: `AlfalfaClient` is an alias of
40
+ > `PacerClient`, and the `alfalfa_client` package/`pip install alfalfa-client` continue to be published with
41
+ > identical functionality (see `alfalfa_client/__init__.py`). `import alfalfa_client` (in any form) emits a
42
+ > `DeprecationWarning` pointing you to `pacer_client`; the `alfalfa-client` name and imports will eventually be
43
+ > removed, so new code should prefer `pacer_client`/`PacerClient`.
44
+
45
+ Additional documentation for the functions of `pacer-client` can be found [here](https://natlabrockies.github.io/alfalfa-client/).
46
+
47
+ ## Development
48
+
49
+ Prerequisites:
50
+
51
+ - [poetry](https://python-poetry.org/docs/#installation) for managing environment
52
+
53
+ Cloning and Installing:
54
+
55
+ ```bash
56
+ git clone https://github.com/NatLabRockies/alfalfa-client.git
57
+ cd alfalfa-client
58
+ poetry install
59
+ ```
60
+
61
+ Running Tests:
62
+ All `pacer-client` tests currently require a running instance of [Alfalfa](https://github.com/NatLabRockies/alfalfa) with at least 2 workers.
63
+
64
+ ```bash
65
+ poetry run pytest -m integration
66
+ ```
67
+
68
+ ## Releasing
69
+
70
+ Every PR must carry at least one categorization label (`breaking`, `enhancement`, `bug`, `dependencies`,
71
+ `github_actions`, `python`, `documentation`, `add test`, or `do not publish` to exclude it) — this is enforced by
72
+ the `Verify Pull Request Labeling` CI check and is what allows GitHub to group the auto-generated release notes
73
+ correctly, per `.github/release.yml`.
74
+
75
+ 1. Confirm all PRs intended for the release are merged into `develop`, are labeled correctly, and CI is passing.
76
+ 1. Bump the version on `develop` (assume version is 0.1.2): `poetry version 0.1.2`, open a PR, and merge it once tests pass.
77
+ 1. Fast-forward `main` to `develop` so both branches point at the same commit (do **not** create a merge commit):
78
+ ```bash
79
+ git checkout main
80
+ git merge --ff-only develop
81
+ git push origin main
82
+ ```
83
+ If `--ff-only` fails, `main` and `develop` have diverged (e.g. a hotfix landed only on `main`) — reconcile that first so the release doesn't leave the branches out of sync.
84
+ 1. Draft a release on the GitHub repository targeting `main` (tag `v0.1.2`) and click **"Generate release notes"**
85
+ (UI), or run `gh release create v0.1.2 --target main --generate-notes --draft` — do not publish yet.
86
+ 1. Copy the generated "## What's Changed" notes into `CHANGELOG.md` under the `# Unreleased` section, then (as part of the release) rename that header to `# pacer-client Version 0.1.2` and add the `**Full Changelog**` line.
87
+ 1. Commit this alongside the version bump PR, or as a quick follow-up commit on `main`.
88
+ 1. Publish the drafted release. This triggers the `PyPIRelease` workflow (`.github/workflows/pypi_release.yml`),
89
+ which builds the package **twice** (once as `pacer-client`, once with the name swapped to `alfalfa-client`) and
90
+ publishes both to PyPI via trusted publishing — no manual `poetry build`/`poetry publish` needed. Both
91
+ distributions have identical contents; this keeps `pip install alfalfa-client` working during the rename
92
+ transition (see the note in the README's Usage section). Publishing to `alfalfa-client` requires a Trusted
93
+ Publisher to be configured on that PyPI project pointing at the `pypi_release_alfalfa` environment — set this
94
+ up once on [pypi.org](https://pypi.org/manage/project/alfalfa-client/settings/publishing/) before the first
95
+ release after this change.
96
+ 1. Confirm the new version appears on both [pacer-client](https://pypi.org/project/pacer-client/) and
97
+ [alfalfa-client](https://pypi.org/project/alfalfa-client/) on PyPI, and that the release notes and
98
+ `CHANGELOG.md` match.
99
+
@@ -0,0 +1,79 @@
1
+ # Pacer Client
2
+
3
+ The purpose of this repository is to provide a standalone client for use with the Alfalfa application. It additionally includes a Historian to quickly/easily enable saving of results from Alfalfa simulations.
4
+
5
+ ## Usage
6
+
7
+ This repo is packaged and hosted on [PyPI here](https://pypi.org/project/pacer-client/).
8
+
9
+ ```bash
10
+ pip install pacer-client
11
+ ```
12
+
13
+ ```python
14
+ from pacer_client.pacer_client import PacerClient
15
+
16
+ client = PacerClient("http://localhost")
17
+ ```
18
+
19
+ > **Renaming from `alfalfa-client`:** this project was renamed from `alfalfa-client`/`AlfalfaClient` to
20
+ > `pacer-client`/`PacerClient`. During the transition, both names keep working: `AlfalfaClient` is an alias of
21
+ > `PacerClient`, and the `alfalfa_client` package/`pip install alfalfa-client` continue to be published with
22
+ > identical functionality (see `alfalfa_client/__init__.py`). `import alfalfa_client` (in any form) emits a
23
+ > `DeprecationWarning` pointing you to `pacer_client`; the `alfalfa-client` name and imports will eventually be
24
+ > removed, so new code should prefer `pacer_client`/`PacerClient`.
25
+
26
+ Additional documentation for the functions of `pacer-client` can be found [here](https://natlabrockies.github.io/alfalfa-client/).
27
+
28
+ ## Development
29
+
30
+ Prerequisites:
31
+
32
+ - [poetry](https://python-poetry.org/docs/#installation) for managing environment
33
+
34
+ Cloning and Installing:
35
+
36
+ ```bash
37
+ git clone https://github.com/NatLabRockies/alfalfa-client.git
38
+ cd alfalfa-client
39
+ poetry install
40
+ ```
41
+
42
+ Running Tests:
43
+ All `pacer-client` tests currently require a running instance of [Alfalfa](https://github.com/NatLabRockies/alfalfa) with at least 2 workers.
44
+
45
+ ```bash
46
+ poetry run pytest -m integration
47
+ ```
48
+
49
+ ## Releasing
50
+
51
+ Every PR must carry at least one categorization label (`breaking`, `enhancement`, `bug`, `dependencies`,
52
+ `github_actions`, `python`, `documentation`, `add test`, or `do not publish` to exclude it) — this is enforced by
53
+ the `Verify Pull Request Labeling` CI check and is what allows GitHub to group the auto-generated release notes
54
+ correctly, per `.github/release.yml`.
55
+
56
+ 1. Confirm all PRs intended for the release are merged into `develop`, are labeled correctly, and CI is passing.
57
+ 1. Bump the version on `develop` (assume version is 0.1.2): `poetry version 0.1.2`, open a PR, and merge it once tests pass.
58
+ 1. Fast-forward `main` to `develop` so both branches point at the same commit (do **not** create a merge commit):
59
+ ```bash
60
+ git checkout main
61
+ git merge --ff-only develop
62
+ git push origin main
63
+ ```
64
+ If `--ff-only` fails, `main` and `develop` have diverged (e.g. a hotfix landed only on `main`) — reconcile that first so the release doesn't leave the branches out of sync.
65
+ 1. Draft a release on the GitHub repository targeting `main` (tag `v0.1.2`) and click **"Generate release notes"**
66
+ (UI), or run `gh release create v0.1.2 --target main --generate-notes --draft` — do not publish yet.
67
+ 1. Copy the generated "## What's Changed" notes into `CHANGELOG.md` under the `# Unreleased` section, then (as part of the release) rename that header to `# pacer-client Version 0.1.2` and add the `**Full Changelog**` line.
68
+ 1. Commit this alongside the version bump PR, or as a quick follow-up commit on `main`.
69
+ 1. Publish the drafted release. This triggers the `PyPIRelease` workflow (`.github/workflows/pypi_release.yml`),
70
+ which builds the package **twice** (once as `pacer-client`, once with the name swapped to `alfalfa-client`) and
71
+ publishes both to PyPI via trusted publishing — no manual `poetry build`/`poetry publish` needed. Both
72
+ distributions have identical contents; this keeps `pip install alfalfa-client` working during the rename
73
+ transition (see the note in the README's Usage section). Publishing to `alfalfa-client` requires a Trusted
74
+ Publisher to be configured on that PyPI project pointing at the `pypi_release_alfalfa` environment — set this
75
+ up once on [pypi.org](https://pypi.org/manage/project/alfalfa-client/settings/publishing/) before the first
76
+ release after this change.
77
+ 1. Confirm the new version appears on both [pacer-client](https://pypi.org/project/pacer-client/) and
78
+ [alfalfa-client](https://pypi.org/project/alfalfa-client/) on PyPI, and that the release notes and
79
+ `CHANGELOG.md` match.
@@ -0,0 +1,22 @@
1
+ # :copyright (c) Alliance for Energy Innovation, LLC, and other contributors.
2
+ # See also https://github.com/NatLabRockies/alfalfa-client/blob/develop/LICENSE.txt
3
+
4
+ # `alfalfa_client` is a compatibility package: `alfalfa-client` is being renamed to
5
+ # `pacer-client`. This package re-exports the `pacer_client` API under the original
6
+ # `alfalfa_client` names/import paths so existing code keeps working unchanged while
7
+ # both names are supported. New code should prefer `import pacer_client`.
8
+
9
+ import warnings
10
+
11
+ from alfalfa_client.alfalfa_client import AlfalfaClient
12
+
13
+ warnings.warn(
14
+ "The 'alfalfa-client' package is being renamed to 'pacer-client'. `alfalfa_client` / "
15
+ "`AlfalfaClient` still work today, but this compatibility package will be removed in a "
16
+ "future release. Please migrate to `import pacer_client` / `from pacer_client import "
17
+ "PacerClient` (`pip install pacer-client`).",
18
+ DeprecationWarning,
19
+ stacklevel=2,
20
+ )
21
+
22
+ __all__ = ["AlfalfaClient"]
@@ -0,0 +1,10 @@
1
+ # :copyright (c) Alliance for Energy Innovation, LLC, and other contributors.
2
+ # See also https://github.com/NatLabRockies/alfalfa-client/blob/develop/LICENSE.txt
3
+
4
+ # Compatibility shim for the pre-rename `alfalfa_client.alfalfa_client` module.
5
+ # `AlfalfaClient` and `PacerClient` are the same class; see `pacer_client.pacer_client`.
6
+
7
+ from pacer_client.pacer_client import ModelID, PacerClient, RunID
8
+ from pacer_client.pacer_client import PacerClient as AlfalfaClient
9
+
10
+ __all__ = ["AlfalfaClient", "ModelID", "PacerClient", "RunID"]
@@ -0,0 +1,28 @@
1
+ # :copyright (c) Alliance for Energy Innovation, LLC, and other contributors.
2
+ # See also https://github.com/NatLabRockies/alfalfa-client/blob/develop/LICENSE.txt
3
+
4
+ # Compatibility shim for the pre-rename `alfalfa_client.lib` module. These helpers were
5
+ # never renamed (they describe the Alfalfa server, not the client package), so this is
6
+ # a straight re-export of `pacer_client.lib`.
7
+
8
+ from pacer_client.lib import (
9
+ AlfalfaAPIException,
10
+ AlfalfaClientException,
11
+ AlfalfaException,
12
+ AlfalfaWorkerException,
13
+ common_root,
14
+ create_zip,
15
+ parallelize,
16
+ prepare_model,
17
+ )
18
+
19
+ __all__ = [
20
+ "AlfalfaAPIException",
21
+ "AlfalfaClientException",
22
+ "AlfalfaException",
23
+ "AlfalfaWorkerException",
24
+ "common_root",
25
+ "create_zip",
26
+ "parallelize",
27
+ "prepare_model",
28
+ ]
@@ -0,0 +1,6 @@
1
+ # :copyright (c) Alliance for Energy Innovation, LLC, and other contributors.
2
+ # See also https://github.com/NatLabRockies/alfalfa-client/blob/develop/LICENSE.txt
3
+
4
+ from pacer_client.pacer_client import AlfalfaClient, PacerClient
5
+
6
+ __all__ = ["AlfalfaClient", "PacerClient"]
@@ -0,0 +1,148 @@
1
+ # :copyright (c) Alliance for Energy Innovation, LLC, and other contributors.
2
+ # See also https://github.com/NatLabRockies/alfalfa-client/blob/develop/LICENSE.txt
3
+
4
+ import concurrent.futures
5
+ import functools
6
+ import json
7
+ import tempfile
8
+ from functools import partial
9
+ from os import PathLike
10
+ from pathlib import Path
11
+ from zipfile import ZipFile
12
+
13
+ from requests import Response
14
+
15
+
16
+ def parallelize(func):
17
+ """Parallelize a function
18
+ Decorator which, when applied to a function, will parallelize the function
19
+ on the first non-self parameter. If a list is passed n instances of the
20
+ original function will be called inside Threads. The results will be returned
21
+ as a list with the same order as the original. If a list is not passed, the
22
+ original function will be called.
23
+
24
+ """
25
+
26
+ def parallel_call(func, iter_vals: list, args: list = [], kwargs: dict = {}):
27
+ responses = [None] * len(iter_vals)
28
+ with concurrent.futures.ThreadPoolExecutor() as executor:
29
+ future_to_index = {executor.submit(func, iter_vals[i], *args, **kwargs): i for i in range(len(iter_vals))}
30
+ for future in concurrent.futures.as_completed(future_to_index):
31
+ arg = future_to_index[future]
32
+
33
+ responses[arg] = future.result()
34
+ return responses
35
+
36
+ @functools.wraps(func)
37
+ def parallel_wrapper(self, *args, **kwargs):
38
+ # Find the first parameter as either an arg or kwarg
39
+ if len(args) > 0:
40
+ val = args[0]
41
+ args = args[1:]
42
+ else:
43
+ first_varname = func.__code__.co_varnames[1]
44
+ if first_varname in kwargs:
45
+ val = kwargs[first_varname]
46
+ del kwargs[first_varname]
47
+ else:
48
+ raise TypeError(f"{func.__name__}() missing 1 required positional argument: '{first_varname}'")
49
+
50
+ if isinstance(val, list):
51
+ return parallel_call(partial(func, self), val, args, kwargs)
52
+ else:
53
+ return func(self, val, *args, **kwargs)
54
+
55
+ return parallel_wrapper
56
+
57
+
58
+ def common_root(*paths: PathLike) -> Path:
59
+ path_parts = []
60
+ min_len = 0
61
+ for path in paths:
62
+ parts = list(Path(path).parts)
63
+ if len(parts) < min_len or min_len == 0:
64
+ min_len = len(parts)
65
+ path_parts.append(parts)
66
+
67
+ root_parts = path_parts[0][0:min_len]
68
+ path_parts.pop(0)
69
+ while len(root_parts) > 0:
70
+ current_min_len = min_len
71
+ part = root_parts[-1]
72
+ for parts in path_parts:
73
+ if part != parts[min_len - 1]:
74
+ root_parts.pop()
75
+ min_len -= 1
76
+ break
77
+ if current_min_len == min_len:
78
+ break
79
+
80
+ return Path(*root_parts)
81
+
82
+
83
+ def create_zip(*paths: PathLike) -> str:
84
+ """Create Zip
85
+ Takes a directory and creates a temporary zip file of it.
86
+
87
+ :param dir: directory to create zip of
88
+
89
+ :returns: path of zip file
90
+ """
91
+ paths = [Path(path) for path in paths]
92
+ root_dir = common_root(*paths)
93
+ _zip_file_fd, zip_file_path = tempfile.mkstemp(prefix=root_dir.stem, suffix=".zip")
94
+ zip_file = ZipFile(zip_file_path, "w")
95
+ for path in paths:
96
+ if path.is_file():
97
+ zip_file.write(path, path.relative_to(root_dir))
98
+ for file_path in path.glob("**/*"):
99
+ zip_file.write(file_path, file_path.relative_to(root_dir))
100
+
101
+ zip_file.close()
102
+
103
+ return zip_file_path
104
+
105
+
106
+ def prepare_model(model_path: PathLike) -> str:
107
+ """Prepares model for upload
108
+ Takes a file or directory. If the input is a file it returns the file.
109
+ If the input is a directory it zips the directory and returns the path to that zip.
110
+
111
+ :param model_path: path to model
112
+
113
+ :returns: path of prepared model
114
+ """
115
+ model_path = Path(model_path)
116
+ if model_path.is_dir():
117
+ return str(create_zip(str(model_path.absolute())))
118
+ else:
119
+ return str(model_path.absolute())
120
+
121
+
122
+ class AlfalfaException(Exception):
123
+ """Wrapper for exceptions which come from alfalfa"""
124
+
125
+
126
+ class AlfalfaWorkerException(AlfalfaException):
127
+ """Wrapper for exceptions from the alfalfa worker"""
128
+
129
+
130
+ class AlfalfaAPIException(AlfalfaException):
131
+ """Wrapper for API errors"""
132
+
133
+ def __init__(self, response: Response, *args: object) -> None:
134
+ self.response = response
135
+ body = response.json()
136
+ super().__init__(body["message"], *args)
137
+
138
+ if "payload" in body:
139
+ self.payload = json.dumps(body["payload"])
140
+
141
+ def __str__(self) -> str:
142
+ if hasattr(self, "payload"):
143
+ return super().__str__() + "\nAPI Payload: \n" + json.dumps(self.payload)
144
+ return super().__str__()
145
+
146
+
147
+ class AlfalfaClientException(AlfalfaException):
148
+ """Wrapper for exceptions in client operation"""
@@ -0,0 +1,319 @@
1
+ # :copyright (c) Alliance for Energy Innovation, LLC, and other contributors.
2
+ # See also https://github.com/NatLabRockies/alfalfa-client/blob/develop/LICENSE.txt
3
+
4
+ import errno
5
+ import json
6
+ import os
7
+ from collections import OrderedDict
8
+ from datetime import datetime
9
+ from time import sleep, time
10
+ from urllib.parse import urljoin
11
+
12
+ import requests
13
+ from requests_toolbelt import MultipartEncoder
14
+
15
+ from pacer_client.lib import AlfalfaAPIException, AlfalfaClientException, AlfalfaException, parallelize, prepare_model
16
+
17
+ ModelID = str
18
+ RunID = str
19
+
20
+
21
+ class PacerClient:
22
+ """PacerClient is a wrapper for the Alfalfa REST API"""
23
+
24
+ def __init__(self, host: str = "http://localhost", api_version: str = "v2"):
25
+ """Create a new alfalfa client instance
26
+
27
+ :param host: url for host of alfalfa web server
28
+ :param api_version: version of alfalfa api to use (probably don't change this)
29
+ """
30
+ self.host = host.rstrip("/")
31
+ self.haystack_filter = self.host + "/haystack/read?filter="
32
+ self.haystack_json_header = {"Content-Type": "application/json", "Accept": "application/json"}
33
+
34
+ self.host = host
35
+ self.api_version = api_version
36
+ self.point_translation_map = {}
37
+
38
+ @property
39
+ def url(self):
40
+ return urljoin(self.host, f"api/{self.api_version}/")
41
+
42
+ def _request(self, endpoint: str, method="POST", parameters=None) -> requests.Response:
43
+ if parameters:
44
+ response = requests.request(
45
+ method=method, url=self.url + endpoint, json=parameters, headers={"Content-Type": "application/json"}
46
+ )
47
+ else:
48
+ response = requests.request(method=method, url=self.url + endpoint)
49
+
50
+ if response.status_code >= 400:
51
+ try:
52
+ raise AlfalfaAPIException(response)
53
+ except json.JSONDecodeError:
54
+ pass
55
+ response.raise_for_status()
56
+
57
+ return response
58
+
59
+ @parallelize
60
+ def status(self, run_id: RunID | list[RunID]) -> str:
61
+ """Get status of run
62
+
63
+ :param run_id: id of run or list of ids
64
+ :returns: status of run
65
+ """
66
+ response = self._request(f"runs/{run_id}", method="GET").json()
67
+ return response["payload"]["status"]
68
+
69
+ @parallelize
70
+ def get_error_log(self, run_id: RunID | list[RunID]) -> str:
71
+ """Get error log from run
72
+
73
+ :param run_id: id of run or list of ids
74
+ :returns: error log from run
75
+ """
76
+ response = self._request(f"runs/{run_id}", method="GET").json()
77
+ return response["payload"]["errorLog"]
78
+
79
+ @parallelize
80
+ def wait(self, run_id: RunID | list[RunID], desired_status: str, timeout: float = 600) -> None:
81
+ """Wait for a run to have a certain status or timeout with error
82
+
83
+ :param run_id: id of run or list of ids
84
+ :param desired_status: status to wait for
85
+ :param timeout: timeout length in seconds
86
+ """
87
+
88
+ start_time = time()
89
+ previous_status = None
90
+ current_status = None
91
+ while time() - timeout < start_time:
92
+ try:
93
+ current_status = self.status(run_id)
94
+ except AlfalfaAPIException as e:
95
+ if e.response.status_code != 404:
96
+ raise e
97
+
98
+ if current_status == "ERROR":
99
+ error_log = self.get_error_log(run_id)
100
+ raise AlfalfaException(error_log)
101
+
102
+ if current_status != previous_status:
103
+ print(f"Desired status: {desired_status}\t\tCurrent status: {current_status}")
104
+ previous_status = current_status
105
+ if current_status == desired_status.upper():
106
+ return
107
+ sleep(2)
108
+ raise AlfalfaClientException(f"'wait' timed out waiting for status: '{desired_status}', current status: '{current_status}'")
109
+
110
+ def upload_model(self, model_path: os.PathLike) -> ModelID:
111
+ """Upload a model to alfalfa
112
+
113
+ :param model_path: path to model file or folder or list of paths
114
+
115
+ :returns: id of model"""
116
+ if not os.path.exists(model_path):
117
+ raise FileNotFoundError(errno.ENOENT, os.strerror(errno.ENOENT), model_path)
118
+ model_path = prepare_model(model_path)
119
+ filename = os.path.basename(model_path)
120
+
121
+ payload = {"modelName": filename}
122
+
123
+ response = self._request("models/upload", parameters=payload)
124
+ response_body = response.json()["payload"]
125
+ post_url = response_body["url"]
126
+
127
+ model_id = response_body["modelId"]
128
+ form_data = OrderedDict(response_body["fields"])
129
+ form_data["file"] = ("filename", open(model_path, "rb"))
130
+
131
+ encoder = MultipartEncoder(fields=form_data)
132
+ response = requests.post(post_url, data=encoder, headers={"Content-Type": encoder.content_type})
133
+ response.raise_for_status()
134
+ assert response.status_code == 204, "Model upload failed"
135
+
136
+ return model_id
137
+
138
+ def create_run_from_model(self, model_id: ModelID | list[ModelID], wait_for_status: bool = True) -> RunID:
139
+ """Create a run from a model
140
+
141
+ :param model_id: id of model to create a run from or list of ids
142
+ :param wait_for_status: wait for model to be "READY" before returning
143
+
144
+ :returns: id of run created"""
145
+ response = self._request(f"models/{model_id}/createRun")
146
+ run_id = response.json()["payload"]["runId"]
147
+
148
+ if wait_for_status:
149
+ self.wait(run_id, "ready")
150
+
151
+ return run_id
152
+
153
+ @parallelize
154
+ def submit(self, model_path: str | list[str], wait_for_status: bool = True) -> RunID:
155
+ """Submit a model to alfalfa
156
+
157
+ :param model_path: path to the model to upload or list of paths
158
+ :param wait_for_status: wait for model to be "READY" before returning
159
+
160
+ :returns: id of created run
161
+ :rtype: str"""
162
+
163
+ model_id = self.upload_model(model_path)
164
+
165
+ # After the file has been uploaded, then tell BOPTEST to process the run
166
+ # This is done not via the haystack api, but through a REST api
167
+ run_id = self.create_run_from_model(model_id, wait_for_status=wait_for_status)
168
+
169
+ return run_id
170
+
171
+ @parallelize
172
+ def start(
173
+ self,
174
+ run_id: RunID | list[RunID],
175
+ start_datetime: datetime,
176
+ end_datetime: datetime,
177
+ timescale: int = 5,
178
+ external_clock: bool = False,
179
+ realtime: bool = False,
180
+ wait_for_status: bool = True,
181
+ ):
182
+ """Start one run from a model.
183
+
184
+ :param run_id: id of run or list of ids
185
+ :param start_datetime: time to start the model from
186
+ :param end_datetime: time to stop the model at (may not be honored for external_clock=True)
187
+ :param timescale: multiple of real time to run model at (for external_clock=False)
188
+ :param external_clock: run model with an external advancer
189
+ :param realtime: run model with timescale=1
190
+ :param wait_for_status: wait for model to be "RUNNING" before returning
191
+ """
192
+ parameters = {
193
+ "startDatetime": str(start_datetime),
194
+ "endDatetime": str(end_datetime),
195
+ "timescale": timescale,
196
+ "externalClock": external_clock,
197
+ "realtime": realtime,
198
+ }
199
+
200
+ response = self._request(f"runs/{run_id}/start", parameters=parameters)
201
+
202
+ assert response.status_code == 204, "Got wrong status_code from alfalfa"
203
+
204
+ if wait_for_status:
205
+ self.wait(run_id, "running")
206
+
207
+ @parallelize
208
+ def stop(self, run_id: RunID | list[RunID], wait_for_status: bool = True):
209
+ """Stop a run
210
+
211
+ :param run_id: id of the run or list of ids
212
+ :param wait_for_status: wait for the run to be "complete" before returning
213
+ """
214
+
215
+ response = self._request(f"runs/{run_id}/stop")
216
+
217
+ assert response.status_code == 204, "Got wrong status_code from alfalfa"
218
+
219
+ if wait_for_status:
220
+ self.wait(run_id, "complete")
221
+
222
+ @parallelize
223
+ def advance(self, run_id: RunID | list[RunID]) -> None:
224
+ """Advance a run 1 timestep
225
+
226
+ :param run_id: id of run or list of ids"""
227
+ self._request(f"runs/{run_id}/advance")
228
+
229
+ def get_inputs(self, run_id: str) -> list[str]:
230
+ """Get inputs of run
231
+
232
+ :param run_id: id of run
233
+ :returns: list of input names"""
234
+
235
+ response = self._request(f"runs/{run_id}/points", method="POST", parameters={"pointTypes": ["INPUT", "BIDIRECTIONAL"]})
236
+ response_body = response.json()["payload"]
237
+ inputs = []
238
+ for point in response_body:
239
+ if point["name"] != "":
240
+ inputs.append(point["name"])
241
+ return inputs
242
+
243
+ def set_inputs(self, run_id: str, inputs: dict) -> None:
244
+ """Set inputs of run
245
+
246
+ :param run_id: id of run
247
+ :param inputs: dictionary of point names and input values"""
248
+ point_writes = {}
249
+ for name, value in inputs.items():
250
+ id = self._get_point_translation(run_id, name)
251
+ if id:
252
+ point_writes[id] = value
253
+ else:
254
+ raise AlfalfaClientException(f"No Point exists with name {name}")
255
+ self._request(f"runs/{run_id}/points/values", method="PUT", parameters={"points": point_writes})
256
+
257
+ def get_outputs(self, run_id: str) -> dict:
258
+ """Get outputs of run
259
+
260
+ :param run_id: id of run
261
+ :returns: dictionary of output names and values"""
262
+ response = self._request(f"runs/{run_id}/points/values", method="POST", parameters={"pointTypes": ["OUTPUT", "BIDIRECTIONAL"]})
263
+ response_body = response.json()["payload"]
264
+ outputs = {}
265
+ for point, value in response_body.items():
266
+ name = self._get_point_translation(run_id, point)
267
+ outputs[name] = value
268
+
269
+ return outputs
270
+
271
+ @parallelize
272
+ def get_sim_time(self, run_id: RunID | list[RunID]) -> datetime:
273
+ """Get sim_time of run
274
+
275
+ :param run_id: id of site or list of ids
276
+ :returns: datetime of site
277
+ """
278
+ response = self._request(f"runs/{run_id}/time", method="GET")
279
+ response_body = response.json()["payload"]
280
+ return datetime.strptime(response_body["time"], "%Y-%m-%d %H:%M:%S")
281
+
282
+ def set_alias(self, alias: str, run_id: RunID) -> None:
283
+ """Set alias to point to a run_id
284
+
285
+ :param run_id: id of run to point alias to
286
+ :param alias: alias to use"""
287
+
288
+ self._request(f"aliases/{alias}", method="PUT", parameters={"runId": run_id})
289
+
290
+ def get_alias(self, alias: str) -> RunID:
291
+ """Get run_id from alias
292
+
293
+ :param alias: alias
294
+ :returns: Id of run associated with alias"""
295
+
296
+ response = self._request(f"aliases/{alias}", method="GET")
297
+ response_body = response.json()["payload"]
298
+ return response_body
299
+
300
+ def _get_point_translation(self, *args):
301
+ if args in self.point_translation_map:
302
+ return self.point_translation_map[args]
303
+ if args not in self.point_translation_map:
304
+ self._fetch_points(args[0])
305
+ if args in self.point_translation_map:
306
+ return self.point_translation_map[args]
307
+ return None
308
+
309
+ def _fetch_points(self, run_id):
310
+ response = self._request(f"runs/{run_id}/points", method="GET")
311
+ for point in response.json()["payload"]:
312
+ self.point_translation_map[(run_id, point["name"])] = point["id"]
313
+ self.point_translation_map[(run_id, point["id"])] = point["name"]
314
+
315
+
316
+ # `alfalfa-client` is being renamed to `pacer-client`. `AlfalfaClient` is kept as an
317
+ # alias of `PacerClient` for backwards compatibility while both names are supported;
318
+ # new code should prefer `PacerClient`.
319
+ AlfalfaClient = PacerClient
@@ -0,0 +1,40 @@
1
+ [tool.poetry]
2
+ name = "pacer-client"
3
+ version = "1.1.0rc1"
4
+ description = "A standalone client for the NLR's Alfalfa application"
5
+ authors = [
6
+ 'Kyle Benne <kyle.benne@nlr.gov>',
7
+ 'Brian Ball <brian.ball@nlr.gov>',
8
+ 'Cory Mosiman <cory.mosiman@nlr.gov>',
9
+ 'Nicholas Long <nicholas.long@nlr.gov>',
10
+ 'Willy Bernal <willy.bernalheredia@nlr.gov>',
11
+ 'Yanfei Li <yanfei.li@nlr.gov>',
12
+ 'Tobias Shapinsky <tobias.shapinsky@nlr.gov>'
13
+ ]
14
+ readme = 'README.md'
15
+ license = 'LICENSE.txt'
16
+ # `alfalfa_client` is a backwards-compatibility shim re-exporting `pacer_client`
17
+ # (see alfalfa_client/__init__.py). Both packages ship in every distribution so
18
+ # `pacer-client` and `alfalfa-client` installs stay import-compatible during the
19
+ # rename transition.
20
+ packages = [
21
+ { include = "pacer_client" },
22
+ { include = "alfalfa_client" },
23
+ ]
24
+
25
+ [tool.poetry.dependencies]
26
+ python = ">=3.10"
27
+
28
+ requests-toolbelt = "~1.0"
29
+
30
+ [tool.poetry.group.dev.dependencies]
31
+ ipykernel = ">=6.21.2,<8.0.0"
32
+ sphinx = ">=6.1.3,<9.0.0"
33
+ pre-commit = ">=2.21,<4.7"
34
+ pytest = ">=7.2,<9.2"
35
+ ruff = "^0.15.22"
36
+
37
+
38
+ [build-system]
39
+ requires = ["poetry_core>=1.0.0"]
40
+ build-backend = "poetry.core.masonry.api"