-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathgcount-tasks.py
executable file
·2074 lines (1803 loc) · 90.3 KB
/
gcount-tasks.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
"""
GcountTasks (gcount-tasks) provides task counts and time statistics at
timed intervals for tasks recently reported to BOINC servers. It can be
run on Windows, Linux, or macOS. It is the tkinter GUI version of
count-tasks. Its MVC architecture is modified from examples provided
at https://stackoverflow.com/questions/32864610/ and links therein.
Requires Python 3.6 or later and tkinter (tk/tcl) 8.6 or later.
"""
# Copyright (C) 2021-2024 C.S. Echt, under GNU General Public License
# Standard library imports:
import logging
import sys
import threading
from datetime import datetime
from pathlib import Path
from random import choice
from socket import gethostname
from time import sleep, time
from typing import Union
# Third party imports (tk may not be included in some Python installations):
try:
import tkinter as tk
from tkinter import messagebox, ttk
except (ImportError, ModuleNotFoundError) as error:
sys.exit('This program requires tkinter, which is included with \n'
'Python 3.7+ distributions.\n'
'Install the most recent version or re-install Python and include Tk/Tcl.\n'
'\nOn Linux, you may also need: $ sudo apt-get install python3-tk\n'
f'See also: https://tkdocs.com/tutorial/install.html \n'
f'Error msg: {error}')
# Local program imports:
import count_modules as cmod
from count_modules import (bind_this,
boinc_commands as bcmd,
config_constants as const,
files,
instances,
times,
utils,
)
from count_modules.logs import Logs
PROGRAM_NAME = instances.program_name()
class Notices:
"""
Attributes and methods used by CountModeler.update_notice_text() to
provide notices to the user about the status of BOINC tasks and projects.
Methods:
suspended_by_user
running_out_of_tasks
no_tasks_reported
computation_error
all_is_well
no_tasks
user_suspended_tasks
user_suspended_activity
user_suspended_project
boinc_suspended_tasks
all_stalled
unknown
"""
def __init__(self, share):
self.share = share
# Set instance attributes for use in update_notice_text() dispatch tables and in
# the following Class methods. These attributes are set in
# CountModeler.update_task_status().
self.num_suspended_by_user = self.share.notice['num_suspended_by_user'].get()
self.num_uploading = self.share.notice['num_uploading'].get()
self.num_uploaded = self.share.notice['num_uploaded'].get()
self.num_aborted = self.share.notice['num_aborted'].get()
self.num_err = self.share.notice['num_err'].get()
self.num_tasks_all = self.share.data['num_tasks_all'].get()
self.num_taskless_intervals = self.share.notice['num_taskless_intervals'].get()
self.num_running = self.share.notice['num_running'].get()
self.num_ready_to_report = self.share.notice['num_ready_to_report'].get()
# These methods are called by the tasks_running dispatch table in
# CountModeler.update_notice_text().
def suspended_by_user(self, called_by=None) -> str:
"""
Is used to provide context-specific text for log_it() and
update_notice_text() methods.
Args: called_by: Either 'log' or None. If 'log', then the
method is called from log_it() and the return value is
formatted for logging. If None, then the return value is
formatted for GUI display via update_notice_text() dispatch table.
Returns: A string with the appropriate notice text.
"""
if called_by == 'log':
return f'{self.num_suspended_by_user} tasks were suspended by user.\n'
# The other called_by value is from the update_notice_text() dispatch table.
return (f'{self.num_suspended_by_user} tasks are suspended by user.\n'
f'BOINC will not upload while tasks are suspended.')
@staticmethod
def running_out_of_tasks():
return 'BOINC client is about to run out of tasks.\nCheck BOINC Manager.'
def no_tasks_reported(self):
return ('NO TASKS reported for the prior'
f' {self.num_taskless_intervals} counting interval(s).')
def computation_error(self):
"""Is used in both tasks_running and no_tasks_running dispatch tables."""
return (f'{self.num_err} tasks have a Computation error.\n'
f'Check BOINC Manager. Check the E@H msg boards.')
def all_is_well(self):
self.share.notice_l.config(fg=const.ROW_FG)
return f'All is well (updates every {const.NOTICE_INTERVAL} seconds)'
# These methods are called by the no_tasks_running dispatch table in
# CountModeler.update_notice_text().
def no_tasks(self):
if self.share.notice['no_new_tasks'].get():
return ('BOINC client has no tasks to run!\n'
'Project is set to receive no new tasks.')
return 'BOINC client has no tasks to run!\nCheck BOINC Manager.'
def user_suspended_tasks(self):
return (
f'NO TASKS running; {self.num_suspended_by_user} tasks suspended by user.\n'
'You may want to resume them.'
)
@staticmethod
def user_suspended_activity():
return ('NO TASKS running.\n'
'Activity suspended by user request or Computing preferences.')
@staticmethod
def user_suspended_project():
return ('NO TASKS running.\n'
'Project suspended by user request or Computing preferences.')
@staticmethod
def boinc_suspended_tasks():
return ('NO TASKS running.\n'
'A BOINC Manager "When to suspend" condition was met.\n'
'Edit BOINC Manager Computing preferences if this is a problem.')
def all_stalled(self) -> Union[bool, str]:
"""
Is also called as a condition in the no_tasks_running dispatch
table because its condition is too lengthy to use in the table.
Therefore, to properly evaluate a False condition, must specify
the return value as a Boolean instead of leaving it as None.
"""
if (self.num_uploading +
self.num_uploaded +
self.num_aborted +
self.num_ready_to_report ==
self.num_tasks_all):
return (
'All tasks are stalled Uploading, Ready to report, or Aborted.\n'
'Check your Project message boards for server issues.')
return False
@staticmethod
def unknown():
return '15 sec status update: NO TASKS RUNNING, reason unknown.'
# ############################ MVC Classes #############################
# MVC Modeler: The engine that gets BOINC data and runs count intervals.
class CountModeler:
"""
Counting, statistical analysis, and formatting of BOINC task data.
Communication with the Viewer Class for data display occurs via
the 'share' parameter.
Methods:
default_settings
start_data
update_task_status
interval_data
manage_notices
get_dispatch_table
update_notice_text
post_final_notice
log_it
"""
def __init__(self, share):
self.share = share
self.thread_lock = threading.Lock()
def default_settings(self) -> None:
"""
Set or reset default run parameters in the setting dictionary.
"""
self.share.setting['interval_t'].set('1h')
self.share.setting['interval_m'].set(60)
self.share.intvl_choice['values'] = ('1h', '30m', '20m', '15m', '10m')
self.share.intvl_choice.select_clear()
self.share.setting['summary_t'].set('1d')
self.share.setting['sumry_t_value'].set(1)
self.share.setting['sumry_t_unit'].set('day')
self.share.sumry_unit_choice['values'] = ('day', 'hr', 'min')
self.share.sumry_unit_choice.select_clear()
self.share.setting['cycles_max'].set(1008)
self.share.setting['do_log'].set(True)
self.share.setting['sound_beep'].set(False)
def start_data(self, called_from: str) -> set:
"""
Gather initial task data and times; set data dictionary
control variables. Log data to file if so optioned.
Called from startdata(), interval_data().
Args: called_from: Either 'start' to write starting data to log
or 'interval_data' to use the return set of tasks.
Returns: The set of starting task times.
"""
# As with task names, task times as sec.microsec are unique.
# In the future, may want to inspect task names with
# tnames = bcmd.get_reported('tasks').
ttimes_start = bcmd.get_reported('elapsed time')
self.share.data['task_count'].set(len(ttimes_start))
self.share.data['num_tasks_all'].set(len(bcmd.get_tasks('name')))
startdict = times.boinc_ttimes_stats(ttimes_start)
self.share.data['taskt_avg'].set(startdict['taskt_avg'])
self.share.data['taskt_sd'].set(startdict['taskt_sd'])
self.share.data['taskt_range'].set(
f"{startdict['taskt_min']} -- {startdict['taskt_max']}")
self.share.data['taskt_total'].set(startdict['taskt_total'])
self.share.data['time_prev_cnt'].set('Last hourly BOINC report.')
if self.share.setting['do_log'].get() and called_from == 'start':
self.share.logit('start')
# Begin keeping track of the set of used/old tasks to exclude
# from new tasks; pass to in interval_data() to track tasks
# across intervals.
return set(ttimes_start)
def update_task_status(self) -> None:
"""
Query boinc-client for status of tasks queued, running, and
suspended; set corresponding dictionary tk control variables.
Called from update_notice_text().
"""
self.share.status_time = datetime.now().strftime(const.LONG_FMT)
tasks_all = bcmd.get_tasks('all')
state_all = bcmd.get_state()
# Need the literal task data tag as found in boinccmd stdout;
# the format is same as tag_str in bcmd.get_tasks().
tag = {'name': ' name: ',
'active': ' active_task_state: ',
'state': ' state: ',
'sched state': ' scheduler state: '}
num_tasks_all = len([elem for elem in tasks_all if tag['name'] in elem])
active_task_states = [elem.replace(tag['active'], '') for elem in tasks_all
if tag['active'] in elem]
task_states = [elem.replace(tag['state'], '') for elem in tasks_all
if tag['state'] in elem]
scheduler_states = [elem.replace(tag['sched state'], '') for elem in tasks_all
if tag['sched state'] in elem]
num_running = len(
[task for task in active_task_states if 'EXECUTING' in task])
# Condition when activity is suspended BOINC Manager based on
# Computing preferences for CPU in use.
num_suspended_cpu_busy = len(
[task for task in active_task_states if 'SUSPENDED' in task])
num_suspended_by_user = len(
[task for task in tasks_all if 'suspended via GUI: yes' in task])
# Use as a Boolean variable expressed as 0 or 1.
project_suspended_by_user = len(
[item for item in state_all if 'suspended via GUI: yes' in item])
# Condition when activity is suspended either by user or by BOINC Manager
# based on Computing preferences for "Computer in use" or the time of day.
num_activity_suspended = len(
[task for task in active_task_states if 'UNINITIALIZED' in task and
'scheduled' in scheduler_states])
num_uploading = len(
[task for task in task_states if 'uploading' in task])
num_uploaded = len(
[task for task in task_states if 'uploaded' in task])
num_err = len(
[task for task in task_states if 'compute error' in task])
num_aborted = len(
[task for task in active_task_states if 'ABORT_PENDING' in task])
num_ready_to_report = len(
[task for task in tasks_all if 'ready to report: yes' in task])
self.share.data['num_tasks_all'].set(num_tasks_all)
self.share.notice['num_running'].set(num_running)
self.share.notice['num_suspended_cpu_busy'].set(num_suspended_cpu_busy)
self.share.notice['num_suspended_by_user'].set(num_suspended_by_user)
self.share.notice['project_suspended_by_user'].set(project_suspended_by_user)
self.share.notice['num_activity_suspended'].set(num_activity_suspended)
self.share.notice['no_new_tasks'].set(bcmd.no_new_tasks())
self.share.notice['num_uploading'].set(num_uploading)
self.share.notice['num_uploaded'].set(num_uploaded)
self.share.notice['num_aborted'].set(num_aborted)
self.share.notice['num_err'].set(num_err)
self.share.notice['num_ready_to_report'].set(num_ready_to_report)
def interval_data(self) -> None:
"""
Run timer and countdown clock to update and analyze regular and
summary data for task status and times. Set control variables
for data and notice dictionaries.
Is threaded as interval_thread; started in Viewer.start_threads().
Calls to: get_minutes(), log_it().
"""
# ttimes_used is the set of starting task times.
ttimes_used = self.start_data(called_from='interval_data')
ttimes_new = set()
ttimes_smry = set()
cycles_max = self.share.setting['cycles_max'].get()
interval_m = self.share.setting['interval_m'].get()
reference_time = time()
num_taskless_intervals = 0
sumry_intvl_counts = []
sumry_intvl_ttavgs = []
for cycle in range(cycles_max):
if cycle == 1:
# Need to change button name and function from Start to Interval
# after initial cycle[0] completes and intvl data displays.
# It might be better if statement were in Viewer, but simpler
# to put it here with easy reference to cycle.
self.share.intvl_b.grid(row=0, column=1,
padx=(16, 0), pady=(8, 4))
self.share.starting_b.grid_forget()
# Need to sleep between counts while displaying a countdown timer.
# Need to limit total time of interval to target_elapsed_time,
# in Epoch seconds, b/c each interval sleep cycle will run longer
# than the intended interval. Realized interval time should thus
# not drift by more than 1 second during count_max cycles.
# Without this time limit, each 1h interval would gain ~4s.
interval_sec = interval_m * 60
target_elapsed_time = reference_time + (interval_sec * (cycle + 1))
for _sec in range(interval_sec):
if cycle == cycles_max:
break
if time() > target_elapsed_time:
self.share.data['time_next_cnt'].set('00:00')
break
interval_sec -= 1
# Need to show the time remaining in clock time format.
self.share.data['time_next_cnt'].set(
times.sec_to_format(interval_sec, 'clock'))
sleep(1.0)
# NOTE: Starting tasks are not included in interval and summary
# counts, but starting task times are used here to determine
# "new" tasks.
# Need to add all prior tasks to the "used" set.
# "new" task times are carried over from the prior interval cycle.
# For cycle[0], ttimes_used is starting tasks from start_data()
# and ttimes_new is empty.
with self.thread_lock:
ttimes_used.update(ttimes_new)
ttimes_reported = set(bcmd.get_reported('elapsed time'))
# Need to reset prior ttimes_new, then repopulate it with only
# newly reported tasks.
ttimes_new.clear()
ttimes_new = ttimes_reported - ttimes_used
task_count_new = len(ttimes_new)
self.share.data['task_count'].set(task_count_new)
cycles_remain = int(self.share.data['cycles_remain'].get()) - 1
self.share.data['cycles_remain'].set(cycles_remain)
# Display weekday with time of previous interval to aid the user.
self.share.data['time_prev_cnt'].set(
datetime.now().strftime(const.DAY_FMT))
# Capture full ending time here, instead of in log_it(),
# so that the logged time matches displayed time.
self.share.data['time_intvl_count'].set(
datetime.now().strftime(const.LONG_FMT))
# Track when no new tasks were reported in past interval;
# num_taskless_intervals used in get_dispatch_table().
# Need to update num_running value from task_states().
self.update_task_status()
num_running = self.share.notice['num_running'].get()
if task_count_new == 0:
num_taskless_intervals += 1
elif task_count_new > 0 and num_running > 0:
num_taskless_intervals = 0
self.share.notice['num_taskless_intervals'].set(num_taskless_intervals)
intervaldict = times.boinc_ttimes_stats(ttimes_new)
self.share.data['taskt_avg'].set(intervaldict['taskt_avg'])
self.share.data['taskt_sd'].set(intervaldict['taskt_sd'])
self.share.data['taskt_range'].set(
f"{intervaldict['taskt_min']} -- {intervaldict['taskt_max']}")
self.share.data['taskt_total'].set(intervaldict['taskt_total'])
# SUMMARY DATA #########################################
# NOTE: Starting data are not included in summary tabulations.
# Need to gather interval times and counts for ea. interval in
# a summary segment to calc weighted mean times. This sumry
# list has a different function than the ttimes_smry set.
sumry_intvl_counts.append(task_count_new)
sumry_intvl_ttavgs.append(self.share.data['taskt_avg'].get())
ttimes_smry.update(ttimes_new)
summary_m = times.string_to_min(self.share.setting['summary_t'].get())
# When summary interval is >= 1 week, need to provide date of
# prior summary rather than weekday, as above (%A %H:%M).
# Take care that the summary time_now exactly matches the
# time of the last interval in the summary period.
if summary_m >= 10080:
self.share.data['time_prev_cnt'].set(
datetime.now().strftime(const.SHORTER_FMT))
if (cycle + 1) % (summary_m // interval_m) == 0:
self.update_summary_data(
time_prev=self.share.data['time_prev_cnt'].get(),
tasks=ttimes_smry,
averages=sumry_intvl_ttavgs,
counts=sumry_intvl_counts
)
# Need to reset data for the next summary interval.
ttimes_smry.clear()
sumry_intvl_ttavgs.clear()
sumry_intvl_counts.clear()
# Call to log_it() needs to be outside the thread lock.
app.update_idletasks()
if self.share.setting['do_log'].get():
self.share.logit('interval')
def update_summary_data(self,
time_prev: str,
tasks: set,
averages: list,
counts: list) -> None:
"""
Set summary data for the most recent interval.
Called from CountModeler.interval_data().
Calls times.logtimes_stat() and times.boinc_ttimes_stats().
Args:
time_prev: The time of the previous summary interval.
tasks: A set of task times for the most recent interval.
averages: A list of average task times for each interval.
counts: A list of new task counts since previous interval.
Returns:
None
"""
# Flag used in log_it() to log summary data.
self.share.data['log_summary'].set(True)
# Need to deactivate tooltip and activate the Summary
# data button now; only need this for the first Summary
# but, oh well, here we go again...
utils.Tooltip(widget=self.share.sumry_b, tt_text='', state='disabled')
self.share.sumry_b.config(state=tk.NORMAL)
# Set time and stats of summary count.
self.share.data['time_prev_sumry'].set(time_prev)
self.share.data['task_count_sumry'].set(len(tasks))
summarydict = times.boinc_ttimes_stats(tasks)
self.share.data['taskt_sd_sumry'].set(summarydict['taskt_sd'])
self.share.data['taskt_range_sumry'].set(
f"{summarydict['taskt_min']} -- {summarydict['taskt_max']}")
self.share.data['taskt_total_sumry'].set(summarydict['taskt_total'])
# Need the weighted mean summary task time, not the average
# (arithmetic mean) value.
taskt_weighted_mean: str = times.logtimes_stat(
distribution=averages,
stat='weighted_mean',
weights=counts)
self.share.data['taskt_mean_sumry'].set(taskt_weighted_mean)
def manage_notices(self):
"""
Manages BOINC task state information and notifications by
running on short time intervals. A const.NOTICE_INTERVAL of 15 sec
works well.
Is threaded as notice_thread in CountViewer.start_threads() with
target of Countcontroller.taskstatenotices().
Calls to: Notice(), bcmd.check_boinc_tk(), utils.beep(),
update_notice_text(), and post_final_notice().
"""
while self.share.data['cycles_remain'].get() > 0:
sleep(const.NOTICE_INTERVAL)
bcmd.check_boinc_tk(app)
with self.thread_lock:
self.update_notice_text() # also calls update_task_status().
if (self.share.notice['num_running'].get() == 0
and self.share.setting['sound_beep'].get()):
utils.beep(repeats=2)
if self.share.data['cycles_remain'].get() == 0:
self.post_final_notice()
app.update_idletasks()
# Call to log_it() needs to be outside the thread lock.
if self.share.setting['do_log'].get():
self.share.logit('notice')
def get_dispatch_table(self, Note) -> dict:
""" Returns a dispatch table for update_notice_text() based on whether
tasks running or not. Called from update_notice_text(). Calls Notices()
Args:
Note: An attribute of the Notices class that is used to
obtain current task status values and text relevant to
task status.
Returns:
A dispatch table to post relevant GUI notices based on
current task status conditions.
"""
num_suspended_cpu_busy = self.share.notice['num_suspended_cpu_busy'].get()
num_activity_suspended = self.share.notice['num_activity_suspended'].get()
project_suspended_by_user = self.share.notice['project_suspended_by_user'].get()
# Status values and notice text are from Notices() instances.
# Dispatch table items are in descending order of status
# notification priority.
tasks_running = {
Note.num_suspended_by_user > 0: Note.suspended_by_user,
Note.num_running >= (Note.num_tasks_all - 1): Note.running_out_of_tasks,
Note.num_taskless_intervals > 0: Note.no_tasks_reported,
Note.num_err > 0: Note.computation_error,
}
no_tasks_running = {
Note.num_tasks_all == 0: Note.no_tasks,
Note.num_suspended_by_user > 0: Note.user_suspended_tasks,
num_suspended_cpu_busy > 0: Note.boinc_suspended_tasks,
num_activity_suspended > 0: Note.user_suspended_activity,
project_suspended_by_user: Note.user_suspended_project,
Note.all_stalled(): Note.all_stalled,
Note.num_err > 0: Note.computation_error,
}
return tasks_running if Note.num_running > 0 else no_tasks_running
def update_notice_text(self):
"""
Grabs the most recent task status data.
Called from manage_notices().
Calls Notices() and get_dispatch_table().
"""
self.update_task_status()
Note = Notices(self.share)
dispatch_table = self.get_dispatch_table(Note)
for condition, func in dispatch_table.items():
if condition is True:
self.share.notice_l.config(fg=const.HIGHLIGHT)
self.share.notice['notice_txt'].set(func())
return
# If no known problem is found when no tasks are running,
# then post "reason unknown" notice. Otherwise, post "all is well".
status = 'unknown' if Note.num_running == 0 else 'all is well'
self.share.notice_l.config(
fg=const.HIGHLIGHT if status == 'unknown' else const.ROW_FG)
self.share.notice['notice_txt'].set(
Note.unknown() if status == 'unknown' else Note.all_is_well())
def post_final_notice(self):
"""Called from manage_notices()."""
cycles_max = self.share.setting['cycles_max'].get()
self.share.notice['notice_txt'].set(
f'{self.share.notice["notice_txt"].get()}\n'
f'*** All {cycles_max} count intervals have been run. ***\n')
print(f'\n*** {cycles_max} of {cycles_max} counting cycles have ended. ***\n'
'You can quit the program from the GUI, then restart from the command line.\n')
def log_it(self, called_from: str) -> None:
"""
Write interval and summary metrics for recently reported
BOINC tasks. Provide information on aberrant task status.
Called from start_data(), interval_data(), update_notice_text(), and
CountController.logit().
Is threaded as log_thread in Viewer.start_threads().
:param called_from: Either 'start', 'interval' or 'notice',
depending on type of data to be logged.
"""
# Var used for log text formatting:
indent = ' ' * 22
bigindent = ' ' * 33
cycles_max = self.share.setting['cycles_max'].get()
def log_start():
Logs.check_log_size()
task_count = self.share.data['task_count'].get()
taskt_avg = self.share.data['taskt_avg'].get()
taskt_sd = self.share.data['taskt_sd'].get()
taskt_range = self.share.data['taskt_range'].get()
taskt_total = self.share.data['taskt_total'].get()
num_tasks_all = self.share.data['num_tasks_all'].get()
if cycles_max > 0:
report = (
f'\n>>> START GUI TASK COUNTER v.{cmod.__version__}, SETTINGS: <<<\n'
f'{self.share.long_time_start};'
f' Number of tasks in the most recent BOINC report: {task_count}\n'
f'{indent}Task Time: avg {taskt_avg},\n'
f'{bigindent}range [{taskt_range}],\n'
f'{bigindent}stdev {taskt_sd}, total {taskt_total}\n'
f'{indent}Total tasks in queue: {num_tasks_all}\n'
f'{indent}Number of scheduled count intervals: {cycles_max}\n'
f'{indent}Counts every {self.share.setting["interval_t"].get()},'
f' summaries every {self.share.setting["summary_t"].get()}.\n'
f'{indent}BOINC status evaluations every {const.NOTICE_INTERVAL}s.\n'
'Timed intervals beginning now...\n')
else: # If cycles_max is 0, then the program is in test (status) mode.
report = (
f'\n{self.share.long_time_start}; STATUS REPORT\n'
f'{indent}Number of tasks recently reported by BOINC: {task_count}\n'
f'{indent}Task Time: avg {taskt_avg},\n'
f'{bigindent}range [{taskt_range}],\n'
f'{bigindent}stdev {taskt_sd}, total {taskt_total}\n'
f'{indent}Total tasks in queue: {num_tasks_all}\n')
logging.info(report)
def log_interval():
# Local vars that are either used more than once or to shorten f-strings.
interval_t = self.share.setting['interval_t'].get()
summary_t = self.share.setting['summary_t'].get()
time_intvl_count = self.share.data['time_intvl_count'].get()
taskt_sd = self.share.data['taskt_sd'].get()
cycles_remain = self.share.data['cycles_remain'].get()
logging.info(
f'\n{time_intvl_count}; Tasks reported in the past {interval_t}:'
f' {self.share.data["task_count"].get()}\n'
f'{indent}Task Time: avg {self.share.data["taskt_avg"].get()},\n'
f'{bigindent}range [{self.share.data["taskt_range"].get()}],\n'
f'{bigindent}stdev {taskt_sd}, total {self.share.data["taskt_total"].get()}\n'
f'{indent}Total tasks in queue: {self.share.data["num_tasks_all"].get()}\n'
f'{indent}{cycles_remain} counts remain.')
if self.share.data['log_summary'].get():
logging.info(
f'\n{time_intvl_count}; >>> SUMMARY: Task count for the past'
f" {summary_t}: {self.share.data['task_count_sumry'].get()}\n"
f"{indent}Task Time: mean {self.share.data['taskt_mean_sumry'].get()},\n"
f"{bigindent}range [{self.share.data['taskt_range_sumry'].get()}],\n"
f"{bigindent}stdev {self.share.data['taskt_sd_sumry'].get()},"
f" total {self.share.data['taskt_total_sumry'].get()}")
# Need to reset flag to toggle summary logging.
self.share.data['log_summary'].set(False)
def log_notice():
"""Need to grab the most recent task status data."""
Note = Notices(self.share)
if Note.num_running > 0:
if Note.num_running >= self.share.data['num_tasks_all'].get() - 1:
logging.info(
f'\n{self.share.status_time}; {Note.running_out_of_tasks()}.')
if Note.num_suspended_by_user > 0:
logging.info(
f'\n{self.share.status_time};'
f' {Note.suspended_by_user(called_by="log")}')
if self.share.data['cycles_remain'].get() == 0:
logging.info(
f'\n*** All {cycles_max} count intervals have been run. ***\n'
' Counting has ended.\n')
else: # no tasks are running
# Log detailed status for all true conditions.
# With Note.unknown() as last value in the not_tasks_running dispatch
# dict, log "reason unknown" only when a known problem is not found.
dispatch_table = self.get_dispatch_table(Note)
known_problem = False
for condition, func in dispatch_table.items():
if condition is True:
logging.info(
f'\n{self.share.status_time}; {func()}')
known_problem = True
if known_problem is False:
logging.info(
f'\n{self.share.status_time}; {Note.unknown()}')
logging_functions = {
'start': log_start,
'interval': log_interval,
'notice': log_notice,
}
with self.thread_lock:
self.update_task_status()
# Need this condition to avoid key error from the threading arg
# of None used for CountController.logit() in start_threads().
try:
if called_from:
logging_functions[called_from]()
except KeyError as err:
print('The called_from param in logit() is expected to be'
'"start", "notice", "interval" or None.\n', err)
# ###### MVC Viewer: the tkinter GUI engine; runs in main thread. ######
class CountViewer(tk.Frame):
"""
The MVC Viewer represents the master Frame for the main window.
All main window GUI and data widgets are defined here. Communication
with the Modeler Class for data manipulation occurs via the 'share'
parameter.
Methods:
setup_widgets
master_labels
master_menus_and_buttons
master_layout
master_row_headers
grid_master_widgets
startup_settings
settings_tooltips
confirm_settings
start_when_confirmed
start_threads
emphasize_start_data
starting_tooltips
emphasize_intvl_data
emphasize_sumry_data
app_got_focus
app_lost_focus
"""
def __init__(self, share):
super().__init__()
self.share = share
self.dataframe = tk.Frame()
self.menubar = tk.Menu()
self.sep1 = ttk.Frame()
self.sep2 = ttk.Frame()
# settings() window widgets:
self.settings_win = tk.Toplevel()
self.share.intvl_choice = ttk.Combobox(self.settings_win)
self.sumry_value_entry = ttk.Entry(self.settings_win)
self.share.sumry_unit_choice = ttk.Combobox(self.settings_win)
self.cycles_max_entry = ttk.Entry(self.settings_win)
self.countnow_button = ttk.Button(self.settings_win)
self.log_choice = tk.Checkbutton(self.settings_win)
self.beep_choice = tk.Checkbutton(self.settings_win)
# Control variables for basic run parameters/settings passed
# between Viewer and Modeler.
self.share.setting = {
'time_start': tk.StringVar(),
'interval_t': tk.StringVar(),
'interval_m': tk.IntVar(),
'sumry_t_value': tk.StringVar(),
'sumry_t_unit': tk.StringVar(),
'summary_t': tk.StringVar(),
'cycles_max': tk.IntVar(),
'do_log': tk.BooleanVar(),
'sound_beep': tk.BooleanVar()
}
# Control variables for display in master; data passed between
# Viewer and Modeler.
self.share.data = {
# Start and Interval data
'task_count': tk.IntVar(),
'taskt_avg': tk.StringVar(),
'taskt_sd': tk.StringVar(),
'taskt_range': tk.StringVar(),
'taskt_total': tk.StringVar(),
'time_intvl_count': tk.StringVar(),
# General data
'time_prev_cnt': tk.StringVar(),
'cycles_remain': tk.IntVar(),
'time_next_cnt': tk.StringVar(),
'num_tasks_all': tk.IntVar(),
# Summary data
'time_prev_sumry': tk.StringVar(),
'task_count_sumry': tk.IntVar(),
'taskt_mean_sumry': tk.StringVar(),
'taskt_sd_sumry': tk.StringVar(),
'taskt_range_sumry': tk.StringVar(),
'taskt_total_sumry': tk.StringVar(),
'log_summary': tk.BooleanVar(),
}
# Control variables for notices and logging passed between
# Viewer and Modeler and between Modeler threads.
self.share.notice = {
'notice_txt': tk.StringVar(),
'num_running': tk.IntVar(),
'num_taskless_intervals': tk.IntVar(),
'project_suspended_by_user': tk.BooleanVar(),
'no_new_tasks': tk.BooleanVar(),
'num_suspended_by_user': tk.IntVar(),
'num_suspended_cpu_busy': tk.IntVar(),
'num_activity_suspended': tk.IntVar(),
'num_uploading': tk.IntVar(),
'num_uploaded': tk.IntVar(),
'num_aborted': tk.IntVar(),
'num_err': tk.IntVar(),
'num_ready_to_report': tk.IntVar(),
}
# This style is used only to configure viewlog_b color in
# app_got_focus() and app_lost_focus().
# self.master is implicit as the parent.
self.view_button_style = ttk.Style()
# Need to define image as a class variable, not a local var in methods.
self.info_button_img = tk.PhotoImage(
file=files.valid_path_to('images/info_button20.png'))
def setup_widgets(self):
"""
Set up all widgets for the main window.
Called from CountController.__init__().
Returns: None
"""
self.master_labels()
self.master_menus_and_buttons()
self.master_layout()
self.master_row_headers()
self.grid_master_widgets()
self.share.defaultsettings()
self.startup_settings()
self.settings_tooltips()
def master_labels(self):
"""
Configure all labels for the main window. Called from setup_widgets().
Returns: None
"""
start_params = dict(
master=self.dataframe,
bg=const.DATA_BG)
boinc_lbl_params = dict(
master=self.dataframe,
font=const.LABEL_FONT,
width=3,
bg=const.DATA_BG)
master_row_params = dict(
bg=const.MASTER_BG,
fg=const.ROW_FG)
master_highlight_params = dict(
bg=const.MASTER_BG,
fg=const.HIGHLIGHT)
start_labels = (
'time_start', 'interval_t', 'summary_t', 'cycles_max'
)
start_params = (
start_params, start_params, start_params, master_row_params
)
for label, param in zip(start_labels, start_params):
setattr(self, f'{label}_l',
tk.Label(**param, textvariable=self.share.setting[label]))
# Labels for settings values; gridded in master_layout(). They are
# fully configured here simply to reduce number of lines in code.
# NOTE: self.time_start_l label is initially configured with text to
# show a startup message, then reconfigured in emphasize_start_data()
# to show the time_start.
self.time_start_l.config(fg=const.EMPHASIZE)
self.interval_t_l.config(width=21, borderwidth=2,
relief='groove')
self.summary_t_l.config(width=21, borderwidth=2,
relief='groove')
# Labels for BOINC data.
boinc_data_labels = (
'task_count', 'taskt_avg', 'taskt_sd', 'taskt_range', 'taskt_total',
'task_count_sumry', 'taskt_mean_sumry', 'taskt_sd_sumry',
'taskt_range_sumry', 'taskt_total_sumry'
)
for label in boinc_data_labels:
setattr(self, f'{label}_l',
tk.Label(**boinc_lbl_params, textvariable=self.share.data[label]))
master_data_labels = (
'time_prev_cnt', 'time_prev_sumry', 'cycles_remain',
'num_tasks_all', 'time_next_cnt'
)
master_params = (
master_row_params, master_row_params, master_row_params,
master_row_params, master_highlight_params
)
for label, param in zip(master_data_labels, master_params):
setattr(self, f'{label}_l',
tk.Label(**param, textvariable=self.share.data[label]))
# Text for compliment_l is configured in compliment_me()
self.share.compliment_l = tk.Label(**master_highlight_params, )
self.share.notice_l = tk.Label(**master_highlight_params,
textvariable=self.share.notice['notice_txt'],
relief='flat', border=0)
def master_menus_and_buttons(self) -> None:
"""
Create master app menus and buttons. Called from setup_widgets().
"""
# Note that self.master is an internal attribute of the
# BaseWidget Class in tkinter's __init__.pyi. Here it refers to
# the CountController() Tk mainloop window. In CountViewer lambda
# functions, MAY use 'app' in place of self.master, but outside
# CountViewer, MUST use 'app' for any mainloop reference.
self.master.config(menu=self.menubar)
# Add pull-down menus
file = tk.Menu(self.menubar, tearoff=0)
self.menubar.add_cascade(label='File', menu=file)
file.add_command(
label='Backup log file',
command=lambda: files.save_as(Logs.LOGFILE))
file.add_command(
label='Backup analysis file',
command=lambda: files.save_as(Logs.ANALYSISFILE))
file.add_separator()
file.add_command(label='Quit', command=lambda: utils.quit_gui(app),
accelerator='Ctrl+Q')
# ^^ Note: use Ctrl+Q for macOS also to call utils.quit_gui;
# Cmd+Q still works.
view = tk.Menu(self.menubar, tearoff=0)
self.menubar.add_cascade(label='View', menu=view)
view.add_command(label='Log file',
command=lambda: Logs.view(filepath=Logs.LOGFILE, tk_obj=app),
# MacOS: can't display 'Cmd+L' b/c won't override native cmd.
accelerator='Ctrl+L')
view.add_command(label='Plot of log data',
command=lambda: Logs.analyze_logfile(do_plot=True),
accelerator='Ctrl+Shift+P')
view.add_command(label='Analysis of log data',
command=lambda: Logs.show_analysis(tk_obj=app),
accelerator='Ctrl+Shift+L')
view.add_command(label='Saved Analyses',
command=lambda: Logs.view(Logs.ANALYSISFILE, tk_obj=app),
accelerator='Ctrl+Shift+A')
help_menu = tk.Menu(self.menubar, tearoff=0)
self.menubar.add_cascade(label='Help', menu=help_menu)
help_menu.add_command(label='Information',
command=self.share.info)
help_menu.add_command(label='Compliment',
command=self.share.compliment,
accelerator='Ctrl+Shift+C')
help_menu.add_command(label='File paths',
command=lambda: self.share.filepaths(window=app))
help_menu.add_command(label='Test example data',
command=lambda: Logs.show_analysis(tk_obj=app, do_test=True))
help_menu.add_command(label='About',
command=self.share.about)
# For Button constructors, self.master is implicit as the parent.
self.share.viewlog_b = ttk.Button(
text='View log file',
command=lambda: Logs.view(Logs.LOGFILE, tk_obj=app),
takefocus=False)
# Set interval & summary focus button attributes with *.share.* b/c need
# to reconfigure them in Modeler.
# starting_b will be replaced with an active ttk intvl_b after first
# interval completes; it is re-gridded in interval_data().
# Need to distinguish system start from program start after 1st BOINC report.
# starting_b is tk.B b/c ttk.B doesn't use disabledforeground keyword.
start_txt = 'Starting data'if bcmd.get_reported('elapsed time') else 'Waiting for data'
self.share.starting_b = tk.Button(
text=start_txt, width=18,