serverwatcher 2.1.1__tar.gz → 2.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: serverwatcher
3
- Version: 2.1.1
3
+ Version: 2.2.1
4
4
  Summary: A HungerLib-powered Minecraft server automation engine.
5
5
  Author: iFamished
6
6
  License: MIT
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
7
7
 
8
8
  [project]
9
9
  name = "serverwatcher"
10
- version = "2.1.1"
10
+ version = "2.2.1"
11
11
  description = "A HungerLib-powered Minecraft server automation engine."
12
12
  readme = "README.md"
13
13
  requires-python = ">=3.10"
@@ -32,7 +32,33 @@ class WatcherMessages:
32
32
  log_gap_high: str = "Gap {gap}. Scheduling restart in 1 hour."
33
33
 
34
34
  # reason messages
35
- reason_restart_soon: str = "The server is set to restart soon"
35
+ reason_restart_soonfrom dataclasses import dataclass, field
36
+
37
+ @dataclass
38
+ class WatcherMessages:
39
+ prefix: str
40
+
41
+ log_start: str = "Evaluating server health..."
42
+ log_validation_fail: str = "Validation failed!"
43
+ log_no_restart: str = "No restart needed."
44
+ log_immediate_restart: str = "Restarting immediately..."
45
+ log_scheduled: str = "Scheduling restart..."
46
+ log_gap_low: str = "Gap is low ({gap}), using low-gap schedule."
47
+ log_gap_high: str = "Gap is high ({gap}), using high-gap schedule."
48
+
49
+ reason_restart_soon: str = "Restart soon schedule is active."
50
+ reason_ram: str = "RAM {ram}GB >= threshold {threshold}GB"
51
+ reason_cpu: str = "CPU {cpu}% >= threshold {threshold}%"
52
+ reason_uptime: str = "Uptime {uptime} >= {threshold} hours"
53
+ reason_tps: str = "TPS {tps} <= threshold {threshold}"
54
+ reason_low_uptime: str = "Uptime too low ({uptime})"
55
+ reason_players: str = "There {verb} {count} {plural} online."
56
+
57
+ broadcast_restart_at: str = "<yellow>Server restart scheduled at {time}"
58
+
59
+ broadcast_minute: dict = field(default_factory=dict)
60
+ broadcast_second: dict = field(default_factory=dict)
61
+ : str = "The server is set to restart soon"
36
62
  reason_ram: str = "RAM usage ({ram}) is higher than {threshold} GB"
37
63
  reason_cpu: str = "CPU usage ({cpu}) is higher than {threshold}%"
38
64
  reason_uptime: str = "Uptime {uptime} exceeds {threshold}h"
@@ -0,0 +1,261 @@
1
+ import time
2
+ from zoneinfo import ZoneInfo
3
+
4
+ from hungerlib import Panel, HungerLogger
5
+ from hungerlib.servers import MinecraftServer, GenericServer
6
+ from hungerlib.addons import (
7
+ clearTerminal,
8
+ Snapshot,
9
+ snapSchedule,
10
+ waitForOnline,
11
+ validateAll,
12
+ runCountdownEvents,
13
+ )
14
+ from hungerlib.addons import configloader
15
+
16
+ from .config import WatcherConfig
17
+ from .messages import WatcherMessages
18
+
19
+
20
+ DEFAULT_CONFIG = {
21
+ "panel": {
22
+ "name": "My Panel",
23
+ "url": "https://example.com",
24
+ "api_key": "CHANGE_ME",
25
+ },
26
+ "origin": {
27
+ "server_id": "CHANGE_ME",
28
+ },
29
+ "server": {
30
+ "name": "My SMP",
31
+ "server_id": "CHANGE_ME",
32
+ "domain": "example.com",
33
+ "port": 25565,
34
+ "rcon_port": 25575,
35
+ "rcon_password": "password",
36
+ "tps_command": "ticks",
37
+ },
38
+ "watcher": {},
39
+ "messages": {},
40
+ }
41
+
42
+
43
+ class ServerWatcher:
44
+ def __init__(self, config_path: str = "config.yaml"):
45
+ # ensure config exists, then load it
46
+ ensure_yaml(config_path, DEFAULT_CONFIG)
47
+ raw = load_yaml(config_path)
48
+
49
+ # map watcher + messages sections into your dataclasses
50
+ self.cfg: WatcherConfig = map_to_dataclass(raw.get("watcher", {}), WatcherConfig)
51
+ self.msg: WatcherMessages = map_to_dataclass(
52
+ raw.get("messages", {}), WatcherMessages
53
+ )
54
+
55
+ # panel / origin / server wiring from YAML
56
+ p = raw["panel"]
57
+ self.panel = Panel(
58
+ name=p["name"],
59
+ url=p["url"],
60
+ api_key=p["api_key"],
61
+ )
62
+
63
+ o = raw["origin"]
64
+ self.origin = GenericServer(
65
+ name="Origin",
66
+ panel=self.panel,
67
+ server_id=o["server_id"],
68
+ )
69
+
70
+ s = raw["server"]
71
+ self.server = MinecraftServer(
72
+ name=s["name"],
73
+ panel=self.panel,
74
+ server_id=s["server_id"],
75
+ server_domain=s["domain"],
76
+ server_port=s["port"],
77
+ rcon_port=s["rcon_port"],
78
+ rcon_password=s["rcon_password"],
79
+ tpsCommand=s["tps_command"],
80
+ )
81
+
82
+ self.log = HungerLogger(
83
+ name=f"ServerWatcher-{s['name']}",
84
+ server=self.server,
85
+ log_path="/home/container/logs/",
86
+ console_backspaces=8,
87
+ )
88
+
89
+ def fmt(self, template: str, **kwargs):
90
+ return template.format(prefix=self.msg.prefix, **kwargs)
91
+
92
+ def shutdown(self):
93
+ self.log.info("Shutting down ServerWatcher.")
94
+ raise SystemExit
95
+
96
+ def restart_and_wait(self):
97
+ self.origin.disableSchedule(self.cfg.origin_disable_schedule_id)
98
+ self.server.restart()
99
+ self.log.info("Restart action sent. Waiting...")
100
+ time.sleep(self.cfg.restart_wait_seconds)
101
+
102
+ self.log.warn("Checking server status...")
103
+ alive = waitForOnline(
104
+ self.server,
105
+ timeout=self.cfg.restart_online_timeout,
106
+ interval=self.cfg.restart_online_interval,
107
+ )
108
+
109
+ if alive:
110
+ self.log.info("Server is back online!")
111
+ self.server.sendBroadcast(f"{self.msg.prefix}<green>Restart successful!")
112
+ self.origin.enableSchedule(self.cfg.origin_disable_schedule_id)
113
+ else:
114
+ self.log.error("Server failed to restart!")
115
+
116
+ def schedule_restart(self, minutes):
117
+ info = snapSchedule(minimumMinutes=minutes)
118
+ scheduled = info["scheduled"]
119
+
120
+ cst = scheduled.astimezone(ZoneInfo("America/Chicago"))
121
+ time_in_cdt = cst.strftime("%I:%M %p")
122
+
123
+ self.server.sendBroadcast(
124
+ self.fmt(self.msg.broadcast_restart_at, time=time_in_cdt)
125
+ )
126
+
127
+ minute_callbacks = {
128
+ m: (lambda msg=self.fmt(self.msg.broadcast_minute[m]): self.server.sendBroadcast(msg))
129
+ for m in self.msg.broadcast_minute
130
+ }
131
+
132
+ second_callbacks = {
133
+ s: (lambda msg=self.fmt(self.msg.broadcast_second[s]): self.server.sendBroadcast(msg))
134
+ for s in self.msg.broadcast_second
135
+ }
136
+
137
+ runCountdownEvents(
138
+ target_time=scheduled,
139
+ minute_callbacks=minute_callbacks,
140
+ second_callbacks=second_callbacks,
141
+ )
142
+
143
+ def evaluate(self):
144
+ self.log.info(self.msg.log_start)
145
+
146
+ if not validateAll(self.panel, self.server):
147
+ self.log.error(self.msg.log_validation_fail)
148
+ self.shutdown()
149
+
150
+ self.server.refresh()
151
+ snap = Snapshot(self.server, 2, True)
152
+
153
+ pro = 0
154
+ anti = 0
155
+ restart_reasons = []
156
+ no_restart_reasons = []
157
+
158
+ # PRO-RESTART
159
+ if self.server.getSchedule(self.cfg.restart_soon_schedule_id)["is_active"]:
160
+ restart_reasons.append(self.msg.reason_restart_soon)
161
+ pro += self.cfg.weight_restart_soon
162
+
163
+ if snap.ram >= self.cfg.ram_threshold:
164
+ restart_reasons.append(
165
+ self.fmt(
166
+ self.msg.reason_ram,
167
+ ram=snap.ram,
168
+ threshold=self.cfg.ram_threshold,
169
+ )
170
+ )
171
+ pro += round(snap.ram, 0) - 5
172
+
173
+ if snap.cpu >= self.cfg.cpu_threshold:
174
+ restart_reasons.append(
175
+ self.fmt(
176
+ self.msg.reason_cpu,
177
+ cpu=snap.cpu,
178
+ threshold=self.cfg.cpu_threshold,
179
+ )
180
+ )
181
+ pro += self.cfg.weight_cpu
182
+
183
+ if snap.uptime // 3600 >= self.cfg.uptime_hours_threshold:
184
+ restart_reasons.append(
185
+ self.fmt(
186
+ self.msg.reason_uptime,
187
+ uptime=snap.uptime_formatted,
188
+ threshold=self.cfg.uptime_hours_threshold,
189
+ )
190
+ )
191
+ pro += self.cfg.weight_uptime
192
+
193
+ if snap.tps <= self.cfg.tps_threshold:
194
+ restart_reasons.append(
195
+ self.fmt(
196
+ self.msg.reason_tps,
197
+ tps=snap.tps,
198
+ threshold=self.cfg.tps_threshold,
199
+ )
200
+ )
201
+ pro += self.cfg.weight_tps
202
+
203
+ # ANTI-RESTART
204
+ if snap.uptime // 60 < 30:
205
+ no_restart_reasons.append(
206
+ self.fmt(
207
+ self.msg.reason_low_uptime,
208
+ uptime=snap.uptime_formatted,
209
+ )
210
+ )
211
+ anti += self.cfg.weight_low_uptime
212
+
213
+ if snap.players > 0:
214
+ verb = "are" if snap.players != 1 else "is"
215
+ plural = "players" if snap.players != 1 else "player"
216
+ no_restart_reasons.append(
217
+ self.fmt(
218
+ self.msg.reason_players,
219
+ verb=verb,
220
+ count=snap.players,
221
+ plural=plural,
222
+ )
223
+ )
224
+ anti += snap.players * self.cfg.weight_per_player
225
+
226
+ # LOGGING
227
+ for r in restart_reasons:
228
+ self.log.warn(f"- {r}")
229
+ for r in no_restart_reasons:
230
+ self.log.warn(f"- {r}")
231
+
232
+ self.log.warn(f"Pro-restart: {pro}")
233
+ self.log.warn(f"Anti-restart: {anti}")
234
+
235
+ gap = abs(pro - anti)
236
+
237
+ if pro == 0:
238
+ self.log.info(self.msg.log_no_restart)
239
+ return
240
+
241
+ if pro > anti and snap.players == 0:
242
+ self.log.info(self.msg.log_immediate_restart)
243
+ self.restart_and_wait()
244
+ return
245
+
246
+ self.log.info(self.msg.log_scheduled)
247
+
248
+ if gap <= 2:
249
+ self.log.warn(self.fmt(self.msg.log_gap_low, gap=gap))
250
+ self.schedule_restart(self.cfg.low_gap_minutes)
251
+ else:
252
+ self.log.warn(self.fmt(self.msg.log_gap_high, gap=gap))
253
+ self.schedule_restart(self.cfg.high_gap_minutes)
254
+
255
+ self.restart_and_wait()
256
+
257
+ def run(self):
258
+ clearTerminal()
259
+ while True:
260
+ self.evaluate()
261
+ time.sleep(60)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: serverwatcher
3
- Version: 2.1.1
3
+ Version: 2.2.1
4
4
  Summary: A HungerLib-powered Minecraft server automation engine.
5
5
  Author: iFamished
6
6
  License: MIT
@@ -1,337 +0,0 @@
1
- import os
2
- import time
3
- import yaml
4
- from zoneinfo import ZoneInfo
5
-
6
- from hungerlib import Panel, HungerLogger
7
- from hungerlib.addons import clearTerminal, Snapshot, snapSchedule, waitForOnline, validateAll, runCountdownEvents
8
- from hungerlib.servers import MinecraftServer, GenericServer
9
-
10
- # from serverwatcher import WatcherConfig, WatcherMessages
11
-
12
-
13
- DEFAULT_CONFIG = {
14
- "panel": {
15
- "name": "My Panel",
16
- "url": "https://example.com",
17
- "api_key": "CHANGE_ME"
18
- },
19
-
20
- "origin": {
21
- "server_id": "CHANGE_ME"
22
- },
23
-
24
- "server": {
25
- "name": "My SMP",
26
- "server_id": "CHANGE_ME",
27
- "domain": "example.com",
28
- "port": 25565,
29
- "rcon_port": 25575,
30
- "rcon_password": "password",
31
- "tps_command": "ticks"
32
- },
33
-
34
- "watcher": {
35
- "restart_soon_schedule_id": 0,
36
- "origin_disable_schedule_id": 0,
37
-
38
- "thresholds": {
39
- "ram": 6,
40
- "cpu": 150,
41
- "uptime_hours": 12,
42
- "tps": 19.5
43
- },
44
-
45
- "weights": {
46
- "restart_soon": 3,
47
- "ram": 1,
48
- "cpu": 1,
49
- "uptime": 1,
50
- "tps": 1,
51
- "low_uptime": 5,
52
- "per_player": 1
53
- },
54
-
55
- "gaps": {
56
- "low": 120,
57
- "high": 60
58
- },
59
-
60
- "restart": {
61
- "wait_seconds": 45,
62
- "online_timeout": 120,
63
- "online_interval": 2
64
- }
65
- },
66
-
67
- "messages": {
68
- "prefix": "<gray>[Watcher]</gray>",
69
-
70
- "log_start": "Evaluating server health...",
71
- "log_validation_fail": "Validation failed!",
72
- "log_no_restart": "No restart needed.",
73
- "log_immediate_restart": "Restarting immediately...",
74
- "log_scheduled": "Scheduling restart...",
75
- "log_gap_low": "Gap is low ({gap}), using low-gap schedule.",
76
- "log_gap_high": "Gap is high ({gap}), using high-gap schedule.",
77
-
78
- "reason_restart_soon": "Restart soon schedule is active.",
79
- "reason_ram": "RAM {ram}GB >= threshold {threshold}GB",
80
- "reason_cpu": "CPU {cpu}% >= threshold {threshold}%",
81
- "reason_uptime": "Uptime {uptime} >= {threshold} hours",
82
- "reason_tps": "TPS {tps} <= threshold {threshold}",
83
- "reason_low_uptime": "Uptime too low ({uptime})",
84
- "reason_players": "There {verb} {count} {plural} online.",
85
-
86
- "broadcast_restart_at": "<yellow>Server restart scheduled at {time}",
87
-
88
- "broadcast_minute": {
89
- 10: "<red>Restart in 10 minutes!",
90
- 5: "<red>Restart in 5 minutes!",
91
- 1: "<red>Restart in 1 minute!"
92
- },
93
-
94
- "broadcast_second": {
95
- 10: "<red>Restart in 10 seconds!",
96
- 5: "<red>Restart in 5 seconds!",
97
- 1: "<red>Restarting now!"
98
- }
99
- }
100
- }
101
-
102
-
103
- def load_or_create_config(path="config.yaml"):
104
- if not os.path.exists(path):
105
- with open(path, "w") as f:
106
- yaml.dump(DEFAULT_CONFIG, f, sort_keys=False)
107
- print("Generated default config.yaml — please edit it.")
108
- time.sleep(2)
109
- with open(path, "r") as f:
110
- return yaml.safe_load(f)
111
-
112
-
113
- class ServerWatcher:
114
- def __init__(self, server, origin, panel, logger, config, messages):
115
- self.server = server
116
- self.origin = origin
117
- self.panel = panel
118
- self.log = logger
119
- self.cfg = config
120
- self.msg = messages
121
-
122
- def fmt(self, template: str, **kwargs):
123
- return template.format(prefix=self.msg.prefix, **kwargs)
124
-
125
- def shutdown(self):
126
- self.log.info("Shutting down ServerWatcher.")
127
- raise SystemExit
128
-
129
- def restart_and_wait(self):
130
- self.origin.disableSchedule(self.cfg.origin_disable_schedule_id)
131
- self.server.restart()
132
- self.log.info("Restart action sent. Waiting...")
133
- time.sleep(self.cfg.restart_wait_seconds)
134
-
135
- self.log.warn("Checking server status...")
136
- alive = waitForOnline(
137
- self.server,
138
- timeout=self.cfg.restart_online_timeout,
139
- interval=self.cfg.restart_online_interval
140
- )
141
-
142
- if alive:
143
- self.log.info("Server is back online!")
144
- self.server.sendBroadcast(f'{self.msg.prefix}<green>Restart successful!')
145
- self.origin.enableSchedule(self.cfg.origin_disable_schedule_id)
146
- else:
147
- self.log.error("Server failed to restart!")
148
-
149
- def schedule_restart(self, minutes):
150
- info = snapSchedule(minimumMinutes=minutes)
151
- scheduled = info["scheduled"]
152
-
153
- cst = scheduled.astimezone(ZoneInfo("America/Chicago"))
154
- time_in_cdt = cst.strftime("%I:%M %p")
155
-
156
- self.server.sendBroadcast(
157
- self.fmt(self.msg.broadcast_restart_at, time=time_in_cdt)
158
- )
159
-
160
- minute_callbacks = {
161
- m: (lambda msg=self.fmt(self.msg.broadcast_minute[m]):
162
- self.server.sendBroadcast(msg))
163
- for m in self.msg.broadcast_minute
164
- }
165
-
166
- second_callbacks = {
167
- s: (lambda msg=self.fmt(self.msg.broadcast_second[s]):
168
- self.server.sendBroadcast(msg))
169
- for s in self.msg.broadcast_second
170
- }
171
-
172
- runCountdownEvents(
173
- target_time=scheduled,
174
- minute_callbacks=minute_callbacks,
175
- second_callbacks=second_callbacks
176
- )
177
-
178
- def evaluate(self):
179
- self.log.info(self.msg.log_start)
180
-
181
- if not validateAll(self.panel, self.server):
182
- self.log.error(self.msg.log_validation_fail)
183
- self.shutdown()
184
-
185
- self.server.refresh()
186
- snap = Snapshot(self.server, 2, True)
187
-
188
- pro = 0
189
- anti = 0
190
- restart_reasons = []
191
- no_restart_reasons = []
192
-
193
- # PRO-RESTART
194
- if self.server.getSchedule(self.cfg.restart_soon_schedule_id)["is_active"]:
195
- restart_reasons.append(self.msg.reason_restart_soon)
196
- pro += self.cfg.weight_restart_soon
197
-
198
- if snap.ram >= self.cfg.ram_threshold:
199
- restart_reasons.append(
200
- self.fmt(self.msg.reason_ram, ram=snap.ram, threshold=self.cfg.ram_threshold)
201
- )
202
- pro += round(snap.ram, 0) - 5
203
-
204
- if snap.cpu >= self.cfg.cpu_threshold:
205
- restart_reasons.append(
206
- self.fmt(self.msg.reason_cpu, cpu=snap.cpu, threshold=self.cfg.cpu_threshold)
207
- )
208
- pro += self.cfg.weight_cpu
209
-
210
- if snap.uptime // 3600 >= self.cfg.uptime_hours_threshold:
211
- restart_reasons.append(
212
- self.fmt(self.msg.reason_uptime, uptime=snap.uptime_formatted, threshold=self.cfg.uptime_hours_threshold)
213
- )
214
- pro += self.cfg.weight_uptime
215
-
216
- if snap.tps <= self.cfg.tps_threshold:
217
- restart_reasons.append(
218
- self.fmt(self.msg.reason_tps, tps=snap.tps, threshold=self.cfg.tps_threshold)
219
- )
220
- pro += self.cfg.weight_tps
221
-
222
- # ANTI-RESTART
223
- if snap.uptime // 60 < 30:
224
- no_restart_reasons.append(
225
- self.fmt(self.msg.reason_low_uptime, uptime=snap.uptime_formatted)
226
- )
227
- anti += self.cfg.weight_low_uptime
228
-
229
- if snap.players > 0:
230
- verb = "are" if snap.players != 1 else "is"
231
- plural = "players" if snap.players != 1 else "player"
232
- no_restart_reasons.append(
233
- self.fmt(self.msg.reason_players, verb=verb, count=snap.players, plural=plural)
234
- )
235
- anti += snap.players * self.cfg.weight_per_player
236
-
237
- # LOGGING
238
- for r in restart_reasons:
239
- self.log.warn(f"- {r}")
240
- for r in no_restart_reasons:
241
- self.log.warn(f"- {r}")
242
-
243
- self.log.warn(f"Pro-restart: {pro}")
244
- self.log.warn(f"Anti-restart: {anti}")
245
-
246
- gap = abs(pro - anti)
247
-
248
- if pro == 0:
249
- self.log.info(self.msg.log_no_restart)
250
- return
251
-
252
- if pro > anti and snap.players == 0:
253
- self.log.info(self.msg.log_immediate_restart)
254
- self.restart_and_wait()
255
- return
256
-
257
- self.log.info(self.msg.log_scheduled)
258
-
259
- if gap <= 2:
260
- self.log.warn(self.fmt(self.msg.log_gap_low, gap=gap))
261
- self.schedule_restart(self.cfg.low_gap_minutes)
262
- else:
263
- self.log.warn(self.fmt(self.msg.log_gap_high, gap=gap))
264
- self.schedule_restart(self.cfg.high_gap_minutes)
265
-
266
- self.restart_and_wait()
267
-
268
-
269
-
270
- # -------------------------
271
- # MAIN
272
- # -------------------------
273
-
274
- cfg = load_or_create_config()
275
-
276
- panel = Panel(**cfg["panel"])
277
-
278
- origin = GenericServer(
279
- name="Origin",
280
- panel=panel,
281
- server_id=cfg["origin"]["server_id"]
282
- )
283
-
284
- srv = cfg["server"]
285
- server = MinecraftServer(
286
- name=srv["name"],
287
- panel=panel,
288
- server_id=srv["server_id"],
289
- server_domain=srv["domain"],
290
- server_port=srv["port"],
291
- rcon_port=srv["rcon_port"],
292
- rcon_password=srv["rcon_password"],
293
- tpsCommand=srv["tps_command"]
294
- )
295
-
296
- log = HungerLogger(
297
- name=f"ServerWatcher-{srv['name']}",
298
- server=server,
299
- log_path="/home/container/logs/",
300
- console_backspaces=8
301
- )
302
-
303
- w = cfg["watcher"]
304
- config = WatcherConfig(
305
- restart_soon_schedule_id=w["restart_soon_schedule_id"],
306
- origin_disable_schedule_id=w["origin_disable_schedule_id"],
307
-
308
- ram_threshold=w["thresholds"]["ram"],
309
- cpu_threshold=w["thresholds"]["cpu"],
310
- uptime_hours_threshold=w["thresholds"]["uptime_hours"],
311
- tps_threshold=w["thresholds"]["tps"],
312
-
313
- weight_restart_soon=w["weights"]["restart_soon"],
314
- weight_ram=w["weights"]["ram"],
315
- weight_cpu=w["weights"]["cpu"],
316
- weight_uptime=w["weights"]["uptime"],
317
- weight_tps=w["weights"]["tps"],
318
- weight_low_uptime=w["weights"]["low_uptime"],
319
- weight_per_player=w["weights"]["per_player"],
320
-
321
- low_gap_minutes=w["gaps"]["low"],
322
- high_gap_minutes=w["gaps"]["high"],
323
-
324
- restart_wait_seconds=w["restart"]["wait_seconds"],
325
- restart_online_timeout=w["restart"]["online_timeout"],
326
- restart_online_interval=w["restart"]["online_interval"],
327
- )
328
-
329
- messages = WatcherMessages(**cfg["messages"])
330
-
331
- clearTerminal()
332
-
333
- Watcher = ServerWatcher(server, origin, panel, log, config, messages)
334
-
335
- while True:
336
- Watcher.evaluate()
337
- time.sleep(60)
File without changes
File without changes
File without changes