lufah 0.3.3__py3-none-any.whl
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.
- lufah-0.3.3.dist-info/LICENSE +21 -0
- lufah-0.3.3.dist-info/METADATA +154 -0
- lufah-0.3.3.dist-info/RECORD +7 -0
- lufah-0.3.3.dist-info/WHEEL +5 -0
- lufah-0.3.3.dist-info/entry_points.txt +2 -0
- lufah-0.3.3.dist-info/top_level.txt +1 -0
- lufah.py +963 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Kevin Bernhagen
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: lufah
|
|
3
|
+
Version: 0.3.3
|
|
4
|
+
Summary: Command line utility for controlling foldingathome version 8
|
|
5
|
+
Author-email: Kevin Bernhagen <kbernhagen.github@gmail.com>
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2024 Kevin Bernhagen
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
28
|
+
Project-URL: Homepage, https://github.com/kbernhagen/lufah
|
|
29
|
+
Keywords: fah,foldingathome,folding-at-home,folding@home
|
|
30
|
+
Classifier: Development Status :: 3 - Alpha
|
|
31
|
+
Classifier: Programming Language :: Python :: 3
|
|
32
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
33
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
34
|
+
Requires-Python: >=3.8
|
|
35
|
+
Description-Content-Type: text/markdown
|
|
36
|
+
License-File: LICENSE
|
|
37
|
+
Requires-Dist: websockets >=12.0
|
|
38
|
+
|
|
39
|
+
# lufah
|
|
40
|
+
|
|
41
|
+
Little Utility for FAH v8
|
|
42
|
+
|
|
43
|
+
This is a python command line utility script that should
|
|
44
|
+
work on macOS, Linux, and Windows.
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
## Requirements
|
|
48
|
+
|
|
49
|
+
- python 3.8 or later
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
## Install from source
|
|
53
|
+
|
|
54
|
+
macOS / Linux / Windows
|
|
55
|
+
```
|
|
56
|
+
git clone https://github.com/kbernhagen/lufah.git
|
|
57
|
+
cd lufah
|
|
58
|
+
make install-user
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Usage
|
|
62
|
+
|
|
63
|
+
Note that lufah uses unencrypted, direct websocket connections.
|
|
64
|
+
This is what Web Control uses to connect to the local client.
|
|
65
|
+
This has security implications if you enable direct remote access on a client.
|
|
66
|
+
See [HOWTO: Allow v8 Client Remote Control](https://foldingforum.org/viewtopic.php?t=39050)
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
usage: lufah [-h] [-v] [-d] [--version] <peer> <command> ...
|
|
70
|
+
|
|
71
|
+
Little Utility for FAH v8
|
|
72
|
+
|
|
73
|
+
positional arguments:
|
|
74
|
+
<peer> [host][:port][/group] Use "." for localhost. For commands
|
|
75
|
+
"units" and "info", it can be a comma-separated list of
|
|
76
|
+
hosts: host[:port],host[:port],...
|
|
77
|
+
<command>
|
|
78
|
+
status show json snapshot of client state
|
|
79
|
+
units show table of all units by group
|
|
80
|
+
fold
|
|
81
|
+
finish
|
|
82
|
+
pause
|
|
83
|
+
unpause alias for fold
|
|
84
|
+
config get or set config values
|
|
85
|
+
groups show json array of resource group names
|
|
86
|
+
info show peer host and client info
|
|
87
|
+
log show log; use control-c to exit
|
|
88
|
+
watch show incoming messages; use control-c to exit
|
|
89
|
+
start start local client service; peer must be "."
|
|
90
|
+
stop stop local client service; peer must be "."
|
|
91
|
+
|
|
92
|
+
options:
|
|
93
|
+
-h, --help show this help message and exit
|
|
94
|
+
-v, --verbose
|
|
95
|
+
-d, --debug
|
|
96
|
+
--version show program's version number and exit
|
|
97
|
+
|
|
98
|
+
Examples
|
|
99
|
+
|
|
100
|
+
lufah . units
|
|
101
|
+
lufah /rg2 finish
|
|
102
|
+
lufah other.local/rg1 status
|
|
103
|
+
lufah /mygpu1 config cpus 0
|
|
104
|
+
lufah . config -h
|
|
105
|
+
lufah host1,host2,host3 units
|
|
106
|
+
lufah host1,host2,host3 info
|
|
107
|
+
|
|
108
|
+
Notes
|
|
109
|
+
|
|
110
|
+
If not given, the default command is 'units'.
|
|
111
|
+
|
|
112
|
+
All commands except "/group config key value" are supported for fah 8.3.
|
|
113
|
+
Command config may not behave as expected for fah 8.3.
|
|
114
|
+
|
|
115
|
+
Group names for fah 8.1 must:
|
|
116
|
+
begin "/", have only letters, numbers, period, underscore, hyphen
|
|
117
|
+
Group names on 8.3 can have spaces and special chars.
|
|
118
|
+
Web Control 8.3 trims leading and trailing white space when creating groups.
|
|
119
|
+
Group "/" is taken to mean the default group, which is "".
|
|
120
|
+
For a group name actually starting with "/", use prefix "//".
|
|
121
|
+
|
|
122
|
+
An error may not be shown if the initial connection times out.
|
|
123
|
+
If group does not exist on 8.1, this script may hang until silent timeout.
|
|
124
|
+
Config priority does not seem to work. Cores are probably setting priority.
|
|
125
|
+
Commands start and stop are macOS-only.
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Tricks
|
|
129
|
+
|
|
130
|
+
On macOS, if you have [Homebrew](https://brew.sh/) installed,
|
|
131
|
+
you can install `watch` to have an inefficient top-like units display.
|
|
132
|
+
On Linux, you probably already have `watch` installed.
|
|
133
|
+
|
|
134
|
+
```
|
|
135
|
+
brew install watch
|
|
136
|
+
|
|
137
|
+
watch -n 10 lufah $(hostname) units
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## Example Output
|
|
141
|
+
|
|
142
|
+
```
|
|
143
|
+
lufah frotz.local units
|
|
144
|
+
```
|
|
145
|
+
```
|
|
146
|
+
-------------------------------------------------------------------------
|
|
147
|
+
Project CPUs GPUs Status Progress PPD ETA
|
|
148
|
+
-------------------------------------------------------------------------
|
|
149
|
+
frotz/
|
|
150
|
+
18402 8 0 Finishing 35.1% 173973 10 hours 59 mins
|
|
151
|
+
frotz//two
|
|
152
|
+
18490 8 0 Running 80.7% 177621 1 hour 29 mins
|
|
153
|
+
frotz//tree
|
|
154
|
+
```
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
lufah.py,sha256=k5wNKlBkcgyrw7usoh5b4wbI6k_eLqYt052BnavWTOc,30333
|
|
2
|
+
lufah-0.3.3.dist-info/LICENSE,sha256=zTd8uS2X8Ah6s-FtYP27HDjEHSZlwc8cPXhvqv_h7nQ,1072
|
|
3
|
+
lufah-0.3.3.dist-info/METADATA,sha256=GAxH7M3IQmX8k9QQiro64-oDwcL3DBV-jREOHD9UC58,5168
|
|
4
|
+
lufah-0.3.3.dist-info/WHEEL,sha256=oiQVh_5PnQM0E3gPdiz09WCNmwiHDMaGer_elqB3coM,92
|
|
5
|
+
lufah-0.3.3.dist-info/entry_points.txt,sha256=Foe5bvRpPUAAW0lR448qasdd7a4YAjN8HMgt3eJZwl4,37
|
|
6
|
+
lufah-0.3.3.dist-info/top_level.txt,sha256=y1E0eN2EPmU-dy8L2ZR3Ov5fkR6fVYmx1rJxVGcj46g,6
|
|
7
|
+
lufah-0.3.3.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
lufah
|
lufah.py
ADDED
|
@@ -0,0 +1,963 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
|
|
4
|
+
"""
|
|
5
|
+
lufah: Little Utility for FAH v8
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = '0.3.3'
|
|
9
|
+
|
|
10
|
+
__all__ = ['FahClient']
|
|
11
|
+
|
|
12
|
+
__license__ = 'MIT'
|
|
13
|
+
__copyright__ = 'Copyright (c) 2024 Kevin Bernhagen'
|
|
14
|
+
__url__ = 'https://github.com/kbernhagen/lufah'
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
import json
|
|
19
|
+
import asyncio
|
|
20
|
+
import socket
|
|
21
|
+
import argparse
|
|
22
|
+
import re
|
|
23
|
+
import datetime
|
|
24
|
+
import errno
|
|
25
|
+
import math
|
|
26
|
+
from urllib.parse import urlparse
|
|
27
|
+
from urllib.request import urlopen
|
|
28
|
+
|
|
29
|
+
from websockets import connect # pip3 install websockets --user
|
|
30
|
+
from websockets.exceptions import *
|
|
31
|
+
|
|
32
|
+
PROGRAM = os.path.basename(sys.argv[0])
|
|
33
|
+
if PROGRAM.endswith(".py"):
|
|
34
|
+
PROGRAM = PROGRAM[:-3]
|
|
35
|
+
|
|
36
|
+
# TODO:
|
|
37
|
+
# lufah . get <keypath> # show json for snapshot.keypath
|
|
38
|
+
# lufah . create-group <name> # by sending pause to nonexistent group
|
|
39
|
+
# lufah . delete-group <name> # refuse if running WU, unless --force
|
|
40
|
+
# lufah file:~/peers.json units
|
|
41
|
+
# { "peers": ["peer1", "peer2", ...] }
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# FIXME: default is not restricted
|
|
45
|
+
# suggest only allow status, units, log, watch
|
|
46
|
+
DEFAULT_COMMAND = 'units'
|
|
47
|
+
|
|
48
|
+
HIDDEN_COMMANDS = ['x', 'fake'] # experimental stuff
|
|
49
|
+
NO_CLIENT_COMMANDS = ['start', 'stop', 'x']
|
|
50
|
+
|
|
51
|
+
# both as sent to client, and cli command
|
|
52
|
+
SIMPLE_CLIENT_COMMANDS = ['fold', 'finish', 'pause']
|
|
53
|
+
|
|
54
|
+
# TODO: multi peer support
|
|
55
|
+
MULTI_PEER_COMMANDS = ['units', 'info'] #, 'fold', 'finish', 'pause']
|
|
56
|
+
|
|
57
|
+
# allowed cli commands; all visible
|
|
58
|
+
COMMANDS = [
|
|
59
|
+
'status',
|
|
60
|
+
'units',
|
|
61
|
+
'fold',
|
|
62
|
+
'finish',
|
|
63
|
+
'pause',
|
|
64
|
+
'unpause',
|
|
65
|
+
'config',
|
|
66
|
+
'groups',
|
|
67
|
+
'info',
|
|
68
|
+
'log',
|
|
69
|
+
'watch',
|
|
70
|
+
]
|
|
71
|
+
if sys.platform == 'darwin': COMMANDS += ['start', 'stop']
|
|
72
|
+
|
|
73
|
+
COMMAND_ALIASES = {
|
|
74
|
+
# alias : actual
|
|
75
|
+
'unpause' : 'fold',
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
COMMANDS_HELP = dict(
|
|
79
|
+
status = 'show json snapshot of client state',
|
|
80
|
+
pause = '',
|
|
81
|
+
fold = '',
|
|
82
|
+
finish = '',
|
|
83
|
+
log = 'show log; use control-c to exit',
|
|
84
|
+
config = 'get or set config values',
|
|
85
|
+
start = 'start local client service; peer must be "."',
|
|
86
|
+
stop = 'stop local client service; peer must be "."',
|
|
87
|
+
groups = 'show json array of resource group names',
|
|
88
|
+
watch = 'show incoming messages; use control-c to exit',
|
|
89
|
+
units = 'show table of all units by group',
|
|
90
|
+
info = 'show peer host and client info',
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# fah 8.3 config keys
|
|
94
|
+
# valid global/group keys are in json files:
|
|
95
|
+
# https://github.com/FoldingAtHome/fah-client-bastet/tree/master/src/resources
|
|
96
|
+
# note that these are the actual keys with underscore
|
|
97
|
+
|
|
98
|
+
# keys to settings possibly owned by account (if logged in)
|
|
99
|
+
# Joseph says it's safe to change these while logged in
|
|
100
|
+
# it is possible for a machine to differ from account
|
|
101
|
+
# web control will only show the account values when logged in
|
|
102
|
+
GLOBAL_CONFIG_KEYS = ['user', 'team', 'passkey', 'cause']
|
|
103
|
+
|
|
104
|
+
# keys to settings in groups under v8.3; in main config before 8.3
|
|
105
|
+
GROUP_CONFIG_KEYS = ['on_idle', 'beta', 'key', 'cpus']
|
|
106
|
+
|
|
107
|
+
# peers is v8.1.x only, but possibly remains as cruft
|
|
108
|
+
# gpus, paused, finish in main config before 8.3
|
|
109
|
+
READ_ONLY_GLOBAL_KEYS = ["peers", "gpus", "paused", "finish"]
|
|
110
|
+
# should never be changed externally for any fah version
|
|
111
|
+
READ_ONLY_GROUP_KEYS = ["gpus", "paused", "finish"]
|
|
112
|
+
|
|
113
|
+
READ_ONLY_CONFIG_KEYS = READ_ONLY_GLOBAL_KEYS + READ_ONLY_GROUP_KEYS
|
|
114
|
+
VALID_CONFIG_SET_KEYS = GLOBAL_CONFIG_KEYS + GROUP_CONFIG_KEYS
|
|
115
|
+
VALID_CONFIG_GET_KEYS = VALID_CONFIG_SET_KEYS + READ_ONLY_CONFIG_KEYS
|
|
116
|
+
|
|
117
|
+
# removed in 8.3
|
|
118
|
+
DEPRECATED_CONFIG_KEYS = ["fold_anon", "peers", "checkpoint", "priority"]
|
|
119
|
+
|
|
120
|
+
# From Web Control
|
|
121
|
+
# some of these are synthetic (not actual unit.state)
|
|
122
|
+
STATUS_STRINGS = {
|
|
123
|
+
'ASSIGN': 'Requesting work',
|
|
124
|
+
'DOWNLOAD': 'Downloading work',
|
|
125
|
+
'CORE': 'Downloading core',
|
|
126
|
+
'RUN': 'Running',
|
|
127
|
+
'FINISH': 'Finishing',
|
|
128
|
+
'UPLOAD': 'Uploading',
|
|
129
|
+
'CLEAN': 'Cleaning up',
|
|
130
|
+
'WAIT': 'Waiting',
|
|
131
|
+
'PAUSE': 'Paused'
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
_CLIENTS = {}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def eprint(*args, **kwargs):
|
|
139
|
+
print(*args, file=sys.stderr, **kwargs)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def bool_from_string(value):
|
|
143
|
+
if value.lower() in ['true', 'yes', 'on', '1']:
|
|
144
|
+
return True
|
|
145
|
+
elif value.lower() in ['false', 'no', 'off', '0']:
|
|
146
|
+
return False
|
|
147
|
+
else:
|
|
148
|
+
raise Exception(f'error: not a bool string: {value}')
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def value_for_key_path(adict, kp):
|
|
152
|
+
# kp: an array of keys or a dot-separated str of keys
|
|
153
|
+
# like to handle int array indicies in str kp
|
|
154
|
+
# ? import munch/addict/benedict
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# allowed config keys
|
|
159
|
+
VALID_KEYS_VALUES = {
|
|
160
|
+
"fold-anon": {"type": bool_from_string},
|
|
161
|
+
"user": {"default":'Anonymous', "type":str.strip, "re":r'^[^\t\n\r]{1,100}$',
|
|
162
|
+
"help": 'If you are using unusual chars, please use Web Control.'},
|
|
163
|
+
"team": {"default": 0, "type": int, "values": range(0, 0x7FFFFFFF)},
|
|
164
|
+
"passkey": {"default": '', "type": str.lower, "re": r'^[0-9a-fA-F]{32}$',
|
|
165
|
+
"help": 'passkey must be 32 hexadecimal characters'},
|
|
166
|
+
"beta": {"type": bool_from_string},
|
|
167
|
+
# https://api.foldingathome.org/project/cause
|
|
168
|
+
"cause": {"default": 'any', "type": str.lower, "values": [
|
|
169
|
+
"any", "alzheimers", "cancer", "huntingtons",
|
|
170
|
+
"parkinsons", "influenza", "diabetes", "covid-19"]},
|
|
171
|
+
"key": {"default": 0, "type": int, "values": range(0, 0xffffffffffffffff)},
|
|
172
|
+
"on-idle": {"type": bool_from_string},
|
|
173
|
+
"cpus": {"type": int, "values": range(0, 256)},
|
|
174
|
+
"checkpoint": {"default": 15, "type": int, "values": range(3, 30)},
|
|
175
|
+
"priority": {"default": 'idle', "type": str.lower,
|
|
176
|
+
"values": ['idle', 'low', 'normal', 'inherit']},
|
|
177
|
+
# no peer editing; peers not supported by v8.2+
|
|
178
|
+
# it would be an error to directly change gpus, paused, finish
|
|
179
|
+
# get-only config keys
|
|
180
|
+
"peers": {},
|
|
181
|
+
"gpus": {},
|
|
182
|
+
"paused": {},
|
|
183
|
+
"finish": {},
|
|
184
|
+
}
|
|
185
|
+
# resource group r'^\/[\w.-]*$'
|
|
186
|
+
# user recommended ^[0-9a-zA-Z_]+$
|
|
187
|
+
# user not alleged reserved chars ^#|~ other disallowed \s
|
|
188
|
+
# user email
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _fetch_json(url):
|
|
192
|
+
data = None
|
|
193
|
+
# TODO: handle file:...
|
|
194
|
+
response = urlopen(url)
|
|
195
|
+
if response.getcode() == 200:
|
|
196
|
+
data = json.loads(response.read().decode('utf-8'))
|
|
197
|
+
return data
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _fetch_causes(options, **kwargs):
|
|
201
|
+
data = _fetch_json('https://api.foldingathome.org/project/cause')
|
|
202
|
+
if options.verbose: print('causes:', json.dumps(data, indent=2))
|
|
203
|
+
return data
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def validate(options):
|
|
207
|
+
if options.debug: options.verbose = True
|
|
208
|
+
|
|
209
|
+
options.peer = options.peer.lstrip() # ONLY left strip
|
|
210
|
+
if not options.peer: raise Exception(f'error: no peer specified')
|
|
211
|
+
# true validation of peer is done by _uri_and_group_for_peer()
|
|
212
|
+
# TODO: accept file:~/peers.json containing {"peers":[".","host2","host3"]}
|
|
213
|
+
# set options.peers; set options.peer = None
|
|
214
|
+
|
|
215
|
+
if options.debug:
|
|
216
|
+
print(f' peer: {repr(options.peer)}')
|
|
217
|
+
print(f'command: {repr(options.command)}')
|
|
218
|
+
|
|
219
|
+
if options.command in [None, '']:
|
|
220
|
+
options.command = DEFAULT_COMMAND
|
|
221
|
+
else:
|
|
222
|
+
options.command = COMMAND_ALIASES.get(options.command, options.command)
|
|
223
|
+
|
|
224
|
+
if options.debug:
|
|
225
|
+
print(f'command: {repr(options.command)}')
|
|
226
|
+
|
|
227
|
+
if options.command in MULTI_PEER_COMMANDS:
|
|
228
|
+
options.peers = []
|
|
229
|
+
if ',' in options.peer and not '/' in options.peer:
|
|
230
|
+
# assume comma separated list of peers
|
|
231
|
+
peers = options.peer.split(',')
|
|
232
|
+
for peer in peers:
|
|
233
|
+
peer = peer.strip()
|
|
234
|
+
if peer:
|
|
235
|
+
options.peers.append(peer)
|
|
236
|
+
if options.debug:
|
|
237
|
+
print(f' peers: {options.peers!r}')
|
|
238
|
+
else:
|
|
239
|
+
options.peers = [options.peer]
|
|
240
|
+
options.peer = None
|
|
241
|
+
|
|
242
|
+
# validate config key value
|
|
243
|
+
if options.command in ['config']:
|
|
244
|
+
key = options.key
|
|
245
|
+
value = options.value
|
|
246
|
+
keys = VALID_KEYS_VALUES.keys()
|
|
247
|
+
info = VALID_KEYS_VALUES.get(key, {})
|
|
248
|
+
values = info.get('values')
|
|
249
|
+
regex = info.get('re')
|
|
250
|
+
conv = info.get('type')
|
|
251
|
+
default = info.get('default')
|
|
252
|
+
|
|
253
|
+
if options.debug:
|
|
254
|
+
print(f' key: {key}')
|
|
255
|
+
print(f' value: {value}')
|
|
256
|
+
print(f' valid keys: {" ".join(keys)}')
|
|
257
|
+
print(f'valid values: {values}')
|
|
258
|
+
print(f' default: {default}')
|
|
259
|
+
print(f' regex: {regex}')
|
|
260
|
+
print(f'type convert: {conv}')
|
|
261
|
+
|
|
262
|
+
if not key in keys:
|
|
263
|
+
k = ' '.join(keys)
|
|
264
|
+
raise Exception(f'unsupported config key: {key}\nknown keys: {k}')
|
|
265
|
+
|
|
266
|
+
if value is None: return
|
|
267
|
+
|
|
268
|
+
# validate value
|
|
269
|
+
|
|
270
|
+
if default is not None and value == '':
|
|
271
|
+
options.value = default
|
|
272
|
+
return
|
|
273
|
+
|
|
274
|
+
value0 = value
|
|
275
|
+
if conv is not None and callable(conv):
|
|
276
|
+
options.value = value = conv(value)
|
|
277
|
+
|
|
278
|
+
if conv == bool_from_string: return
|
|
279
|
+
|
|
280
|
+
if 'user' == key:
|
|
281
|
+
if len(value.encode('utf-8')) > 100:
|
|
282
|
+
raise Exception(f'error: max user length is 100 bytes')
|
|
283
|
+
if value != value0:
|
|
284
|
+
eprint('warning: leading/trailing whitespace trimmed from user')
|
|
285
|
+
|
|
286
|
+
if values:
|
|
287
|
+
if value in values: return
|
|
288
|
+
m = f'error: invalid value for {key}: {value}\nvalid values: {values}'
|
|
289
|
+
raise Exception(m)
|
|
290
|
+
elif regex:
|
|
291
|
+
if not re.match(regex, value):
|
|
292
|
+
m = f'error: invalid {key} value: {value}'
|
|
293
|
+
help = info.get('help')
|
|
294
|
+
if help: m += '\n' + help
|
|
295
|
+
raise Exception(m)
|
|
296
|
+
return # valid regex match
|
|
297
|
+
else:
|
|
298
|
+
raise Exception(f'error: config {key} is read-only')
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def parse_args():
|
|
302
|
+
global options
|
|
303
|
+
description = 'Little Utility for FAH v8'
|
|
304
|
+
epilog = f'''
|
|
305
|
+
Examples
|
|
306
|
+
|
|
307
|
+
{PROGRAM} . units
|
|
308
|
+
{PROGRAM} /rg2 finish
|
|
309
|
+
{PROGRAM} other.local/rg1 status
|
|
310
|
+
{PROGRAM} /mygpu1 config cpus 0
|
|
311
|
+
{PROGRAM} . config -h
|
|
312
|
+
{PROGRAM} host1,host2,host3 units
|
|
313
|
+
{PROGRAM} host1,host2,host3 info
|
|
314
|
+
|
|
315
|
+
Notes
|
|
316
|
+
|
|
317
|
+
If not given, the default command is {DEFAULT_COMMAND!r}.
|
|
318
|
+
|
|
319
|
+
All commands except "/group config key value" are supported for fah 8.3.
|
|
320
|
+
Command config may not behave as expected for fah 8.3.
|
|
321
|
+
|
|
322
|
+
Group names for fah 8.1 must:
|
|
323
|
+
begin "/", have only letters, numbers, period, underscore, hyphen
|
|
324
|
+
Group names on 8.3 can have spaces and special chars.
|
|
325
|
+
Web Control 8.3 trims leading and trailing white space when creating groups.
|
|
326
|
+
Group "/" is taken to mean the default group, which is "".
|
|
327
|
+
For a group name actually starting with "/", use prefix "//".
|
|
328
|
+
|
|
329
|
+
An error may not be shown if the initial connection times out.
|
|
330
|
+
If group does not exist on 8.1, this script may hang until silent timeout.
|
|
331
|
+
Config priority does not seem to work. Cores are probably setting priority.
|
|
332
|
+
'''
|
|
333
|
+
|
|
334
|
+
if sys.platform == 'darwin':
|
|
335
|
+
epilog += 'Commands start and stop are macOS-only.'
|
|
336
|
+
|
|
337
|
+
if len(sys.argv) == 2 and sys.argv[1] == 'help':
|
|
338
|
+
sys.argv[1] = '-h'
|
|
339
|
+
|
|
340
|
+
parser = argparse.ArgumentParser(
|
|
341
|
+
prog=PROGRAM,
|
|
342
|
+
description=description,
|
|
343
|
+
epilog=epilog,
|
|
344
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
345
|
+
|
|
346
|
+
parser.set_defaults(argparser=None)
|
|
347
|
+
parser.set_defaults(key=None, value=None) # do not remove this
|
|
348
|
+
parser.set_defaults(peer='') # in case peer is not required in future
|
|
349
|
+
parser.set_defaults(peers=[])
|
|
350
|
+
parser.set_defaults(command=None)
|
|
351
|
+
|
|
352
|
+
parser.add_argument('-v', '--verbose', action='store_true')
|
|
353
|
+
parser.add_argument('-d', '--debug', action='store_true')
|
|
354
|
+
parser.add_argument('--version', action='version', version=__version__)
|
|
355
|
+
|
|
356
|
+
help1 = '[host][:port][/group] Use "." for localhost'
|
|
357
|
+
help2 = '''\
|
|
358
|
+
[host][:port][/group]
|
|
359
|
+
Use "." for localhost.
|
|
360
|
+
For commands "units" and "info", it can be a comma-separated list of hosts:
|
|
361
|
+
host[:port],host[:port],...
|
|
362
|
+
'''
|
|
363
|
+
parser.add_argument('peer', metavar='<peer>', help = help2)
|
|
364
|
+
|
|
365
|
+
subparsers = parser.add_subparsers(dest='command', metavar='<command>')
|
|
366
|
+
|
|
367
|
+
for cmd in COMMANDS:
|
|
368
|
+
default_help = ''
|
|
369
|
+
if cmd in COMMAND_ALIASES:
|
|
370
|
+
default_help = 'alias for ' + COMMAND_ALIASES.get(cmd)
|
|
371
|
+
help = COMMANDS_HELP.get(cmd, default_help)
|
|
372
|
+
par = subparsers.add_parser(cmd, description=help, help=help)
|
|
373
|
+
if cmd == 'config':
|
|
374
|
+
# TODO: add subparser for each valid config key
|
|
375
|
+
par.add_argument('key', metavar='<key>',
|
|
376
|
+
help = ' '.join(VALID_KEYS_VALUES.keys()))
|
|
377
|
+
par.add_argument('value', nargs='?', metavar='<value>',
|
|
378
|
+
help = 'a valid config value for given key')
|
|
379
|
+
|
|
380
|
+
for cmd in HIDDEN_COMMANDS:
|
|
381
|
+
subparsers.add_parser(cmd)
|
|
382
|
+
|
|
383
|
+
options = parser.parse_args()
|
|
384
|
+
options.argparser = parser
|
|
385
|
+
validate(options)
|
|
386
|
+
return options
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _uri_and_group_for_peer(peer):
|
|
390
|
+
# do not strip right; 8.3 group names might not be stripped
|
|
391
|
+
# create-group should strip what is specified, as web control does
|
|
392
|
+
if peer: peer = peer.lstrip()
|
|
393
|
+
if peer in [None, '']: return (None, None)
|
|
394
|
+
|
|
395
|
+
uri = peer
|
|
396
|
+
if peer.startswith(':'):
|
|
397
|
+
uri = 'ws://.' + peer
|
|
398
|
+
elif peer.startswith('/'):
|
|
399
|
+
uri = 'ws://.' + peer
|
|
400
|
+
if urlparse(uri).scheme == '':
|
|
401
|
+
uri = 'ws://' + peer
|
|
402
|
+
|
|
403
|
+
u = urlparse(uri)
|
|
404
|
+
|
|
405
|
+
if not u.scheme in ['ws', 'wss']:
|
|
406
|
+
eprint(f'error: scheme {u.scheme} is not supported for peer {repr(peer)}')
|
|
407
|
+
return (None, None)
|
|
408
|
+
|
|
409
|
+
userpass = ''
|
|
410
|
+
user = u.username
|
|
411
|
+
password = u.password
|
|
412
|
+
if user and password: userpass = f'{user}:{password}@'
|
|
413
|
+
|
|
414
|
+
host = u.hostname # can be None
|
|
415
|
+
if host: host = host.strip()
|
|
416
|
+
if host in [None, '', '.', 'localhost', 'localhost.']:
|
|
417
|
+
host = '127.0.0.1'
|
|
418
|
+
else:
|
|
419
|
+
# TODO: validate host regex; maybe disallow numeric IPv6 addresses
|
|
420
|
+
# try to munge a resolvable host name
|
|
421
|
+
# remote in vm is on another subnet; need host.local resolved to ipv4
|
|
422
|
+
if host.endswith('.'): host = host[:-1]
|
|
423
|
+
if host.endswith('.local'): host = host[:-6]
|
|
424
|
+
if not '.' in host:
|
|
425
|
+
try:
|
|
426
|
+
socket.gethostbyname(host)
|
|
427
|
+
except socket.gaierror as e:
|
|
428
|
+
# cannot resolve, try again with '.local', if so use ipv4 addr
|
|
429
|
+
# this will be slow if host does not exist
|
|
430
|
+
# note: we do not catch exception
|
|
431
|
+
# may cause lufah to always use ipv4 running on Windows w 'host.local'
|
|
432
|
+
try:
|
|
433
|
+
host = socket.gethostbyname(host + '.local')
|
|
434
|
+
except socket.gaierror as e:
|
|
435
|
+
m = f'Unable to resolve {repr(host)} or {repr(host + ".local")}'
|
|
436
|
+
raise Exception(m)
|
|
437
|
+
except:
|
|
438
|
+
raise
|
|
439
|
+
|
|
440
|
+
port = u.port or 7396
|
|
441
|
+
|
|
442
|
+
uri = f'{u.scheme}://{userpass}{host}:{port}/api/websocket'
|
|
443
|
+
|
|
444
|
+
# validate and munge group, possibly modify uri for 8.1
|
|
445
|
+
# for v8.3, allow "//" prefix, spaces, special chars
|
|
446
|
+
# None or '' is no group (aka all groups)
|
|
447
|
+
# '/' will be '' (default group)
|
|
448
|
+
# '//' will be '/'
|
|
449
|
+
# '//*' will be '/*'
|
|
450
|
+
# 8.1 group name '/' requires using '//'
|
|
451
|
+
group = u.path
|
|
452
|
+
if group in [None, '']: group = None # no group
|
|
453
|
+
elif group == '/': group = '' # default group
|
|
454
|
+
elif group.startswith("/"): group = group[1:] # strip "/"; can now be ''
|
|
455
|
+
if group and re.match(r'^\/?[\w.-]*$', group):
|
|
456
|
+
# might be connecting to fah 8.1, so append /group
|
|
457
|
+
if not group.startswith('/'): uri += '/'
|
|
458
|
+
uri += group
|
|
459
|
+
|
|
460
|
+
return (uri, group)
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def munged_group_name(options, group, snapshot):
|
|
464
|
+
# returns group name that exists, None, or throws
|
|
465
|
+
# assume v8.3; old group names may persist from upgrade
|
|
466
|
+
# NOTE: must have connected to have snapshot
|
|
467
|
+
# group may be None
|
|
468
|
+
# expect always having first leading '/' removed from cli argument
|
|
469
|
+
# expect user specified '//name' for actual '/name' (already stripped)
|
|
470
|
+
if group is None: return None # no group specified
|
|
471
|
+
if snapshot is None: raise Exception(f'error: snapshot is None')
|
|
472
|
+
orig_group = group
|
|
473
|
+
# get array of actual group names else []
|
|
474
|
+
groups = list(snapshot.get('groups', {}).keys())
|
|
475
|
+
if not groups:
|
|
476
|
+
# for 8.1
|
|
477
|
+
peers = self.data.get('peers', [])
|
|
478
|
+
groups = [s for s in peers if s.startswith("/")]
|
|
479
|
+
if len(group): # don't conflate '' with '/'; both can legit exist
|
|
480
|
+
# check 'groupname' and '/groupname'
|
|
481
|
+
# if both exist, throw
|
|
482
|
+
# '' is always the default group and not checked here
|
|
483
|
+
group0 = '/' + group
|
|
484
|
+
if group0 in groups and group in groups:
|
|
485
|
+
raise Exception(f'error: both "{group}" and "{group0}" exist')
|
|
486
|
+
if group0 in groups and not group in groups:
|
|
487
|
+
group = group0
|
|
488
|
+
if not group in groups:
|
|
489
|
+
raise Exception(f'error: no "{group}" in groups {groups}')
|
|
490
|
+
if options.debug:
|
|
491
|
+
print(f' groups: {groups}')
|
|
492
|
+
print(f'original group: {repr(orig_group)}"')
|
|
493
|
+
print(f' munged group: {repr(group)}')
|
|
494
|
+
return group
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
class FahClient:
|
|
498
|
+
|
|
499
|
+
def __init__(self, peer, name=None):
|
|
500
|
+
self._name = None # if exception, __del__ needs _name to exist
|
|
501
|
+
self._ws = None
|
|
502
|
+
self.data = {} # snapshot
|
|
503
|
+
self._version = (0,0,0) # data.info.version as tuple after connect
|
|
504
|
+
# peer is a pseuso-uri that needs munging
|
|
505
|
+
# NOTE: this may raise
|
|
506
|
+
self._uri, self._group = _uri_and_group_for_peer(peer)
|
|
507
|
+
self._name = name if name else self._uri
|
|
508
|
+
# NOTE: this does not actually connect yet
|
|
509
|
+
self._conn = connect(self._uri, ping_interval=None)
|
|
510
|
+
# TODO: once connected, there is data.info.id, which is a better index
|
|
511
|
+
_CLIENTS[self._name] = self
|
|
512
|
+
if options.debug:
|
|
513
|
+
print(f'Created FahClient: {repr(self._name)}')
|
|
514
|
+
|
|
515
|
+
def __del__(self):
|
|
516
|
+
if options.debug:
|
|
517
|
+
print("Destructor called for", type(self).__name__, self._name)
|
|
518
|
+
if self._name in _CLIENTS: del _CLIENTS[self._name]
|
|
519
|
+
|
|
520
|
+
def version(self): return self._version
|
|
521
|
+
|
|
522
|
+
def groups(self):
|
|
523
|
+
groups = list(self.data.get('groups', {}).keys())
|
|
524
|
+
if not groups:
|
|
525
|
+
peers = self.data.get('peers', [])
|
|
526
|
+
groups = [s for s in peers if s.startswith("/")]
|
|
527
|
+
return groups
|
|
528
|
+
|
|
529
|
+
async def connect(self, *args, **kwargs):
|
|
530
|
+
if self._ws is not None and self._ws.open: return
|
|
531
|
+
if not self._ws:
|
|
532
|
+
if options.verbose: print(f'Opening {self._uri}')
|
|
533
|
+
try:
|
|
534
|
+
self._ws = await self._conn.__aenter__(*args, **kwargs)
|
|
535
|
+
except Exception as e:
|
|
536
|
+
self.data = {}
|
|
537
|
+
self._version = (0,0,0)
|
|
538
|
+
raise Exception(f"Failed to connect to {self._uri}")
|
|
539
|
+
else:
|
|
540
|
+
if options.verbose: print(f"Connected to {self._uri}")
|
|
541
|
+
r = await self._ws.recv()
|
|
542
|
+
snapshot = json.loads(r)
|
|
543
|
+
v = snapshot.get("info", {}).get("version", "0")
|
|
544
|
+
self._version = tuple([int(x) for x in v.split('.')])
|
|
545
|
+
self.data.update(snapshot)
|
|
546
|
+
|
|
547
|
+
async def close(self):
|
|
548
|
+
if self._ws is not None:
|
|
549
|
+
await self._ws.close()
|
|
550
|
+
self._ws = None
|
|
551
|
+
|
|
552
|
+
def update(self, data): # data: json array or dict or string
|
|
553
|
+
if isinstance(v, (dict)):
|
|
554
|
+
# currently, this should not happen
|
|
555
|
+
self.data.update(data)
|
|
556
|
+
elif isinstance(v, (list)):
|
|
557
|
+
pass # self._process_update_list(data)
|
|
558
|
+
# else if string, ignore "ping"
|
|
559
|
+
|
|
560
|
+
async def send(self, message):
|
|
561
|
+
# TODO: check ws is open
|
|
562
|
+
msgstr = None
|
|
563
|
+
if isinstance(message, dict):
|
|
564
|
+
msg = message.copy()
|
|
565
|
+
if not 'time' in msg:
|
|
566
|
+
t = datetime.datetime.utcnow().isoformat() + 'Z'
|
|
567
|
+
msg['time'] = t
|
|
568
|
+
msgstr = json.dumps(msg)
|
|
569
|
+
elif isinstance(message, str):
|
|
570
|
+
msgstr = message
|
|
571
|
+
elif isinstance(message, list):
|
|
572
|
+
# currently, would be invalid
|
|
573
|
+
msgstr = json.dumps(message)
|
|
574
|
+
if options.debug:
|
|
575
|
+
print(f'WOULD BE sending: {msgstr}')
|
|
576
|
+
return
|
|
577
|
+
if options.verbose: print(f'sending: {msgstr}')
|
|
578
|
+
if msgstr:
|
|
579
|
+
await self._ws.send(msgstr)
|
|
580
|
+
|
|
581
|
+
async def sendCommand(self, cmd):
|
|
582
|
+
if cmd == 'unpause': cmd = 'fold'
|
|
583
|
+
# TODO
|
|
584
|
+
pass
|
|
585
|
+
async def sendGlobalConfig(self, config): pass
|
|
586
|
+
async def sendGroupConfig(self, group, config):pass
|
|
587
|
+
|
|
588
|
+
async def __aenter__(self, *args, **kwargs):
|
|
589
|
+
if options.debug: print('entering async context')
|
|
590
|
+
await self.connect(*args, **kwargs)
|
|
591
|
+
|
|
592
|
+
async def __aexit__(self, *args, **kwargs):
|
|
593
|
+
if options.debug: print('exiting async context')
|
|
594
|
+
await self._conn.__aexit__(*args, **kwargs)
|
|
595
|
+
self._ws = None
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
async def status(options, client):
|
|
599
|
+
if not options.debug:
|
|
600
|
+
print(json.dumps(client.data, indent=2))
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
async def command(options, client):
|
|
604
|
+
cmd = options.command
|
|
605
|
+
if not cmd in SIMPLE_CLIENT_COMMANDS:
|
|
606
|
+
raise Exception(f'unknown client command: {cmd}')
|
|
607
|
+
if client.version() < (8,3):
|
|
608
|
+
if cmd == 'fold': cmd = 'unpause'
|
|
609
|
+
msg = {"cmd": cmd}
|
|
610
|
+
else:
|
|
611
|
+
msg = {"state": cmd, "cmd": "state"}
|
|
612
|
+
# NOTE: group would be created if it doesn't exist
|
|
613
|
+
if client._group is not None:
|
|
614
|
+
group = munged_group_name(options, client._group, client.data)
|
|
615
|
+
if group is not None:
|
|
616
|
+
msg["group"] = group
|
|
617
|
+
await client.send(msg)
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
# TODO: refactor into config_get(), config_set(), get_config(snapshot,group)
|
|
621
|
+
async def config(options, client):
|
|
622
|
+
key = options.key
|
|
623
|
+
value = options.value
|
|
624
|
+
if True:
|
|
625
|
+
ver = client.version()
|
|
626
|
+
haveAcct = 0 < len(client.data.get("info", {}).get("account", ''))
|
|
627
|
+
|
|
628
|
+
# FIXME: potential race if groups changes before we write
|
|
629
|
+
# think currently client deletes groups not in group config command
|
|
630
|
+
groups = client.groups() # [] on < 8.3
|
|
631
|
+
# we don't care about 8.1 peer groups because everything is in main config
|
|
632
|
+
# just need to be mindful of possible config.available_cpus
|
|
633
|
+
|
|
634
|
+
if (8,3) <= ver:
|
|
635
|
+
group = munged_group_name(options, client._group, client.data)
|
|
636
|
+
else:
|
|
637
|
+
group = client._group
|
|
638
|
+
|
|
639
|
+
# v8.3 splits config between global(account) and group
|
|
640
|
+
|
|
641
|
+
key0 = key # might exist in 8.1
|
|
642
|
+
key = key.replace('-', '_') # convert cli keys to actual
|
|
643
|
+
|
|
644
|
+
if value is None:
|
|
645
|
+
# print value for key
|
|
646
|
+
if (8,3) <= ver and key in GROUP_CONFIG_KEYS and group is not None:
|
|
647
|
+
# client.data.groups.{group}.config
|
|
648
|
+
conf = client.data.get('groups',{}).get(group,{}).get("config",{})
|
|
649
|
+
print(json.dumps(conf.get(key)))
|
|
650
|
+
else:
|
|
651
|
+
# try getting key, no matter what it is
|
|
652
|
+
conf = client.data.get('config', {})
|
|
653
|
+
print(json.dumps(conf.get(key, conf.get(key0))))
|
|
654
|
+
return
|
|
655
|
+
|
|
656
|
+
if 'cpus' == key:
|
|
657
|
+
maxcpus0 = client.data.get('info', {}).get('cpus', 0)
|
|
658
|
+
# available_cpus in fah v8.1.19 only
|
|
659
|
+
maxcpus = client.data.get('config', {}).get('available_cpus', maxcpus0)
|
|
660
|
+
if value > maxcpus:
|
|
661
|
+
raise Exception(f'error: cpus is greater than available cpus {maxcpus}')
|
|
662
|
+
# FIXME: cpus are in groups on fah 8.3; need to sum cpus across groups
|
|
663
|
+
# available_cpus = maxcpus - total_group_cpus
|
|
664
|
+
# if value > (available_cpus - current_group_cpus)
|
|
665
|
+
# this is simpler if only have one group (the default group)
|
|
666
|
+
# no need to calc available_cpus if new value is 0
|
|
667
|
+
|
|
668
|
+
if (8,3) <= ver:
|
|
669
|
+
if key in DEPRECATED_CONFIG_KEYS:
|
|
670
|
+
raise Exception(f'error: key "{key}" is deprecated in fah 8.3')
|
|
671
|
+
if not key in VALID_CONFIG_SET_KEYS:
|
|
672
|
+
raise Exception(f'error: key "{key}" is not supported in fah 8.3')
|
|
673
|
+
if key in GROUP_CONFIG_KEYS:
|
|
674
|
+
raise Exception(f'error: group config is not supported for fah 8.3')
|
|
675
|
+
if haveAcct and key in GLOBAL_CONFIG_KEYS:
|
|
676
|
+
eprint(f'warning: machine is linked to an account')
|
|
677
|
+
eprint(f'warning: "{key}" "{value}" may be overwritten by account')
|
|
678
|
+
eprint(f'warning: config may not work as expected with fah 8.3')
|
|
679
|
+
|
|
680
|
+
# TODO: don't send if value == current_value
|
|
681
|
+
# TODO: create appropriate 8.3 config.groups dict with all current groups
|
|
682
|
+
# this is *only* global config with 8.3
|
|
683
|
+
conf = {key: value}
|
|
684
|
+
msg = {"cmd":"config", "config":conf}
|
|
685
|
+
await client.send(msg)
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
async def log(options, client):
|
|
689
|
+
# TODO: client.sendLogEnable(True)
|
|
690
|
+
await client.send({"cmd": "log", "enable": True})
|
|
691
|
+
if options.debug: return
|
|
692
|
+
while True:
|
|
693
|
+
# TODO: FahClient needs register_callback, msg recv loop, reconnect option
|
|
694
|
+
r = await client._ws.recv()
|
|
695
|
+
msg = json.loads(r)
|
|
696
|
+
# client doesn't just send arrays
|
|
697
|
+
if isinstance(msg, list) and len(msg) and msg[0] == 'log':
|
|
698
|
+
# ignore msg[1], which is -1 or -2
|
|
699
|
+
v = msg[2]
|
|
700
|
+
if isinstance(v, (list, tuple)):
|
|
701
|
+
for line in v: print(line)
|
|
702
|
+
else:
|
|
703
|
+
print(v)
|
|
704
|
+
sys.stdout.flush()
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
async def experimental(options, **kwargs):
|
|
708
|
+
# ~1 sec delay if remote host ends with '.local'
|
|
709
|
+
client = FahClient(options.peer)
|
|
710
|
+
await client.connect()
|
|
711
|
+
print(json.dumps(client.data, indent=2))
|
|
712
|
+
# ~10 sec delay exiting if don't close first; no async context manager?
|
|
713
|
+
# destructor is called immediately
|
|
714
|
+
await client.close()
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
async def xpeer(options, **kwargs):
|
|
718
|
+
print('_uri_and_group_for_peer: ', _uri_and_group_for_peer(options.peer))
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
async def show_groups(options, client):
|
|
722
|
+
print(json.dumps(client.groups()))
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
async def watch(options, client):
|
|
726
|
+
if options.debug: return
|
|
727
|
+
print(json.dumps(client.data, indent=2))
|
|
728
|
+
# FIXME: this can be slow to react to SIGINT
|
|
729
|
+
async for message in client._ws:
|
|
730
|
+
msg = json.loads(message)
|
|
731
|
+
if isinstance(msg, (list, dict, str)):
|
|
732
|
+
print(json.dumps(msg))
|
|
733
|
+
elif isinstance(msg, bytes):
|
|
734
|
+
print(message.hex())
|
|
735
|
+
sys.stdout.flush()
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def print_units_header():
|
|
739
|
+
empty = ''
|
|
740
|
+
print(f'{empty:-<73}')
|
|
741
|
+
print('Project CPUs GPUs Status Progress PPD ETA')
|
|
742
|
+
print(f'{empty:-<73}')
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
def status_for_unit(client, unit):
|
|
746
|
+
state = unit.get("state", '--') # is never FINISH
|
|
747
|
+
# FIXME: waiting is web control unitview.waiting
|
|
748
|
+
#if unit.get('waiting'): return STATUS_STRINGS.get('WAIT', state)
|
|
749
|
+
if unit.get('pause_reason'):
|
|
750
|
+
return STATUS_STRINGS.get('PAUSE', state)
|
|
751
|
+
if state == 'RUN':
|
|
752
|
+
if client.version() < (8,3):
|
|
753
|
+
paused = client.data.get('config',{}).get('paused', False)
|
|
754
|
+
finish = client.data.get('config',{}).get('finish', False)
|
|
755
|
+
else:
|
|
756
|
+
group = unit.get('group', None)
|
|
757
|
+
if group is not None:
|
|
758
|
+
gconfig = client.data.get('groups',{}).get(group,{}).get('config',{})
|
|
759
|
+
paused = gconfig.get('paused', False)
|
|
760
|
+
finish = gconfig.get('finish', False)
|
|
761
|
+
else:
|
|
762
|
+
paused = True
|
|
763
|
+
finish = False
|
|
764
|
+
if paused: state = 'PAUSE'
|
|
765
|
+
elif finish: state = 'FINISH'
|
|
766
|
+
return STATUS_STRINGS.get(state, state)
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
def print_unit(client, unit):
|
|
770
|
+
if unit is None: return
|
|
771
|
+
# TODO: unit dataclass
|
|
772
|
+
assignment = unit.get("assignment", {})
|
|
773
|
+
project = assignment.get("project", '')
|
|
774
|
+
state = status_for_unit(client, unit)
|
|
775
|
+
cpus = unit.get("cpus", 0)
|
|
776
|
+
gpus = len(unit.get("gpus", []))
|
|
777
|
+
progress = unit.get("progress", 0)
|
|
778
|
+
progress = math.floor(progress * 1000) / 10.0
|
|
779
|
+
progress = str(progress) + '%'
|
|
780
|
+
ppd = unit.get("ppd", 0)
|
|
781
|
+
eta = unit.get("eta", '')
|
|
782
|
+
print(
|
|
783
|
+
f'{project:<7} {cpus:<4} {gpus:<4} {state:<16}{progress:^8}'
|
|
784
|
+
f' {ppd:<8} {eta}')
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
def units_for_group(client, group):
|
|
788
|
+
if client is None:
|
|
789
|
+
# assert ?
|
|
790
|
+
raise Exception('error: units_for_group(client, group): client is None')
|
|
791
|
+
all_units = client.data.get('units', [])
|
|
792
|
+
if group is None or client.version() < (8,3):
|
|
793
|
+
units = all_units
|
|
794
|
+
else:
|
|
795
|
+
units = []
|
|
796
|
+
for unit in all_units:
|
|
797
|
+
g = unit.get('group')
|
|
798
|
+
if g is not None and g == group:
|
|
799
|
+
units.append(unit)
|
|
800
|
+
return units
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
def client_machine_name(client):
|
|
804
|
+
if client is None: return ''
|
|
805
|
+
return client.data.get('info',{}).get('hostname','')
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
def clients_sorted_by_machine_name():
|
|
809
|
+
return sorted(list(_CLIENTS.values()), key=client_machine_name)
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
async def print_units(options, **kwargs):
|
|
813
|
+
if _CLIENTS:
|
|
814
|
+
print_units_header()
|
|
815
|
+
for client in clients_sorted_by_machine_name():
|
|
816
|
+
r = urlparse(client._name)
|
|
817
|
+
name = client_machine_name(client)
|
|
818
|
+
if not name: name = r.hostname
|
|
819
|
+
if r.port and r.port != 7396: name += f':{r.port}'
|
|
820
|
+
if r.path and r.path.startswith('/api/websocket'):
|
|
821
|
+
name += r.path[len('/api/websocket'):]
|
|
822
|
+
groups = client.groups()
|
|
823
|
+
if not groups:
|
|
824
|
+
print(name)
|
|
825
|
+
units = units_for_group(client, None)
|
|
826
|
+
if not units:
|
|
827
|
+
#print(f' no units')
|
|
828
|
+
continue
|
|
829
|
+
for unit in units:
|
|
830
|
+
print_unit(client, unit)
|
|
831
|
+
else:
|
|
832
|
+
for group in groups:
|
|
833
|
+
print(f'{name}/{group}')
|
|
834
|
+
units = units_for_group(client, group)
|
|
835
|
+
if not units:
|
|
836
|
+
#print(f' no units')
|
|
837
|
+
continue
|
|
838
|
+
for unit in units:
|
|
839
|
+
print_unit(client, unit)
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
def print_info(options, client):
|
|
843
|
+
if client is None: return
|
|
844
|
+
info = client.data.get('info',{})
|
|
845
|
+
clientver = info.get('version','')
|
|
846
|
+
os = info.get('os','')
|
|
847
|
+
osver = info.get('os_version','')
|
|
848
|
+
cpu = info.get('cpu','')
|
|
849
|
+
brand = info.get('cpu_brand','')
|
|
850
|
+
cores = info.get('cpus',0)
|
|
851
|
+
host = info.get('hostname','')
|
|
852
|
+
print(f' Host: {host}')
|
|
853
|
+
print(f'Client: {clientver}')
|
|
854
|
+
print(f' OS: {os} {osver}')
|
|
855
|
+
print(f' CPU: {cores} cores, {cpu}, "{brand}"')
|
|
856
|
+
|
|
857
|
+
|
|
858
|
+
def print_info_multi(options, **kwargs):
|
|
859
|
+
clients = clients_sorted_by_machine_name()
|
|
860
|
+
multi = len(clients) > 1
|
|
861
|
+
if multi: print()
|
|
862
|
+
for client in clients:
|
|
863
|
+
print_info(options, client)
|
|
864
|
+
if multi: print()
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
def start_or_stop_local_sevice(options, **kwargs):
|
|
868
|
+
if sys.platform == 'darwin' and options.command in ['start', 'stop']:
|
|
869
|
+
if options.peer != '.':
|
|
870
|
+
raise Exception('peer must be "." for commands start and stop')
|
|
871
|
+
from subprocess import check_call
|
|
872
|
+
note = f'org.foldingathome.fahclient.nobody.{options.command}'
|
|
873
|
+
cmd = ['notifyutil', '-p', note]
|
|
874
|
+
if options.debug:
|
|
875
|
+
print(f'WOULD BE running: {" ".join(cmd)}')
|
|
876
|
+
return
|
|
877
|
+
if options.verbose: print(' '.join(cmd))
|
|
878
|
+
check_call(cmd)
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
COMMANDS_DISPATCH = {
|
|
882
|
+
"status" : status,
|
|
883
|
+
"pause" : command,
|
|
884
|
+
"unpause" : command,
|
|
885
|
+
"fold" : command,
|
|
886
|
+
"finish" : command,
|
|
887
|
+
"log" : log,
|
|
888
|
+
"config" : config,
|
|
889
|
+
"groups" : show_groups,
|
|
890
|
+
"x" : xpeer,
|
|
891
|
+
"watch" : watch,
|
|
892
|
+
"start" : start_or_stop_local_sevice,
|
|
893
|
+
"stop" : start_or_stop_local_sevice,
|
|
894
|
+
"units" : print_units,
|
|
895
|
+
"info" : print_info_multi,
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
|
|
899
|
+
async def main_async():
|
|
900
|
+
options = parse_args()
|
|
901
|
+
|
|
902
|
+
if not options.command in COMMANDS + HIDDEN_COMMANDS:
|
|
903
|
+
raise Exception(f'error: unknown command: {options.command}')
|
|
904
|
+
|
|
905
|
+
func = COMMANDS_DISPATCH.get(options.command)
|
|
906
|
+
if func is None:
|
|
907
|
+
raise Exception(f'error: command {options.command} is not implemented')
|
|
908
|
+
|
|
909
|
+
if options.command in NO_CLIENT_COMMANDS:
|
|
910
|
+
client = None
|
|
911
|
+
elif options.command in MULTI_PEER_COMMANDS:
|
|
912
|
+
client = None
|
|
913
|
+
else:
|
|
914
|
+
client = FahClient(options.peer)
|
|
915
|
+
await client.connect()
|
|
916
|
+
|
|
917
|
+
if options.command in MULTI_PEER_COMMANDS:
|
|
918
|
+
for peer in options.peers:
|
|
919
|
+
c = FahClient(peer)
|
|
920
|
+
if c is not None:
|
|
921
|
+
await c.connect()
|
|
922
|
+
|
|
923
|
+
try:
|
|
924
|
+
if asyncio.iscoroutinefunction(func):
|
|
925
|
+
await func(options, client=client)
|
|
926
|
+
else:
|
|
927
|
+
func(options, client=client)
|
|
928
|
+
except ConnectionClosed:
|
|
929
|
+
if options.verbose: eprint('Connection closed')
|
|
930
|
+
finally:
|
|
931
|
+
for c in _CLIENTS.values():
|
|
932
|
+
try:
|
|
933
|
+
await c.close()
|
|
934
|
+
except: pass
|
|
935
|
+
|
|
936
|
+
|
|
937
|
+
def main():
|
|
938
|
+
try:
|
|
939
|
+
asyncio.run(main_async())
|
|
940
|
+
except (KeyboardInterrupt, EOFError):
|
|
941
|
+
print('\n')
|
|
942
|
+
except BrokenPipeError:
|
|
943
|
+
# Python flushes standard streams on exit; redirect remaining output
|
|
944
|
+
# to devnull to avoid another BrokenPipeError at shutdown
|
|
945
|
+
devnull = os.open(os.devnull, os.O_WRONLY)
|
|
946
|
+
os.dup2(devnull, sys.stdout.fileno())
|
|
947
|
+
except IOError as e:
|
|
948
|
+
if e.errno != errno.EPIPE:
|
|
949
|
+
eprint(e)
|
|
950
|
+
sys.exit(1)
|
|
951
|
+
devnull = os.open(os.devnull, os.O_WRONLY)
|
|
952
|
+
os.dup2(devnull, sys.stdout.fileno())
|
|
953
|
+
except OSError as e:
|
|
954
|
+
# Windows may raise this
|
|
955
|
+
if e.errno != 22:
|
|
956
|
+
eprint(e)
|
|
957
|
+
sys.exit(1)
|
|
958
|
+
except Exception as e:
|
|
959
|
+
eprint(e)
|
|
960
|
+
sys.exit(1)
|
|
961
|
+
|
|
962
|
+
if __name__ == '__main__':
|
|
963
|
+
main()
|