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.
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: bdist_wheel (0.42.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ lufah = lufah:main
@@ -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()