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.
- pacer_client-1.1.0rc1/LICENSE.txt +22 -0
- pacer_client-1.1.0rc1/PKG-INFO +99 -0
- pacer_client-1.1.0rc1/README.md +79 -0
- pacer_client-1.1.0rc1/alfalfa_client/__init__.py +22 -0
- pacer_client-1.1.0rc1/alfalfa_client/alfalfa_client.py +10 -0
- pacer_client-1.1.0rc1/alfalfa_client/lib.py +28 -0
- pacer_client-1.1.0rc1/pacer_client/__init__.py +6 -0
- pacer_client-1.1.0rc1/pacer_client/lib.py +148 -0
- pacer_client-1.1.0rc1/pacer_client/pacer_client.py +319 -0
- pacer_client-1.1.0rc1/pyproject.toml +40 -0
|
@@ -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"
|