Merge ~sylvain-pineau/checkbox-support:run_watcher_systemd into checkbox-support:master

Proposed by Sylvain Pineau
Status: Merged
Approved by: Sylvain Pineau
Approved revision: 139e4e61c6ec113cb8cabcf28f01bfca350e4eb7
Merged at revision: cff67a7d24f257fcfece504ee05b1658df1c9cba
Proposed branch: ~sylvain-pineau/checkbox-support:run_watcher_systemd
Merge into: checkbox-support:master
Diff against target: 683 lines (+158/-480)
2 files modified
checkbox_support/scripts/run_watcher.py (+158/-204)
dev/null (+0/-276)
Reviewer Review Type Date Requested Status
Jonathan Cave (community) Approve
Sylvain Pineau (community) Needs Resubmitting
Maciej Kisielewski (community) Approve
Review via email: mp+353572@code.launchpad.net

Commit message

accessing syslog is not possible on UC18 and the same events can be found in systemd journal.

This MR updates the run watcher usb tool to rely on python3-systemd to still get the insert/remove events.

as part of this transition, the script now uses a class instead of globals.
log_watcher was only needed by this script so removed.

Tested on Desktop classic 18.04

To post a comment you must log in.
Revision history for this message
Maciej Kisielewski (kissiel) wrote :

Code looks good. +1
And I love (+158/-480).

Total nitpick would be that some not perfect code got copied. Like string interpolation in logger calls and using .warning() when stuff is an error.

review: Approve
Revision history for this message
Jonathan Cave (jocave) wrote :

I'm testing this on a gen2c board and the device insertion is not being detected. Would like to check the appropriate messages and being generated and searched for.

review: Needs Information
Revision history for this message
Sylvain Pineau (sylvain-pineau) wrote :

Fixed Maciej's nitpicks

For info, the parts update is here: https://code.launchpad.net/~sylvain-pineau/checkbox/+git/checkbox-dev-parts/+merge/353699

review: Needs Resubmitting
Revision history for this message
Jonathan Cave (jocave) wrote :

My findings are that the code works perfectly *if* the systemd libraries used by the checkbox snap match the version of systemd that is running the system journal.

i.e. if the device is an Ubuntu Core 18 device then the checkbox snap must have been built on bionic and target core18. If the device is a Ubuntu Core 16 device the snap must have been built on xenial

Therefore I see no problem with the code, but I think we need firm plan on how we are going to test devices before landing this.

Revision history for this message
Jonathan Cave (jocave) wrote :

We are now in a position to build snaps for different series so I think we can land this.

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
diff --git a/checkbox_support/log_watcher.py b/checkbox_support/log_watcher.py
0deleted file mode 1006440deleted file mode 100644
index 4ae2da5..0000000
--- a/checkbox_support/log_watcher.py
+++ /dev/null
@@ -1,276 +0,0 @@
1#!/usr/bin/env python3
2"""
3Real-time log files watcher supporting log rotation.
4
5Works with Python >= 2.6 and >= 3.2, on both POSIX and Windows.
6
7
8License: MIT
9
10Original work Copyright (c) Giampaolo Rodola' <g.rodola [AT] gmail [DOT] com>
11Modified work Copyright (c) 2015-2016: Taihsiang Ho <tai271828@gmail.com>
12
13Permission is hereby granted, free of charge, to any person obtaining a copy of
14this software and associated documentation files (the "Software"), to deal in
15the Software without restriction, including without limitation the rights to
16use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
17of the Software, and to permit persons to whom the Software is furnished to do
18so, subject to the following conditions:
19
20The above copyright notice and this permission notice shall be included in all
21copies or substantial portions of the Software.
22
23THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
24IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
25FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
26AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
27LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
28OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
29SOFTWARE.
30"""
31import os
32import time
33import errno
34import stat
35
36
37class LogWatcher(object):
38
39 """
40 Looks for changes in all files of a directory.
41
42 This is useful for watching log file changes in real-time.
43 It also supports files rotation.
44
45 Example:
46
47 >>> def callback(filename, lines):
48 ... print(filename, lines)
49 ...
50 >>> lw = LogWatcher("/var/log/", callback)
51 >>> lw.loop()
52 """
53
54 def __init__(self, folder: str, callback: 'Callable[[str], List[str]]',
55 extensions: "List[str]"=None, logfile: str=None,
56 tail_lines: int=0,
57 sizehint: int=1048576):
58 """
59 Initialize a new log watcher.
60
61 :param folder: str
62 the folder to watch
63
64 :param callback: callback
65 a function which is called every time one of the file being
66 watched is updated;
67 this is called with "filename" and "lines" arguments.
68
69 :param extensions: list
70 only watch files with these extensions
71
72 :param logfile: str
73 only watch this file. if this var exists,
74 it will override extention list above.
75
76 :param tail_lines: int
77 read last N lines from files being watched before starting
78
79 :param sizehint: int
80 passed to file.readlines(), represents an
81 approximation of the maximum number of bytes to read from
82 a file on every ieration (as opposed to load the entire
83 file in memory until EOF is reached). Defaults to 1MB.
84 """
85 self.folder = os.path.realpath(folder)
86 self.extensions = extensions
87 self.logfile = logfile
88 self._files_map = {}
89 self._callback = callback
90 self._sizehint = sizehint
91 assert os.path.isdir(self.folder), self.folder
92 assert callable(callback), repr(callback)
93 self.update_files()
94 for id, file in self._files_map.items():
95 file.seek(os.path.getsize(file.name)) # EOF
96 if tail_lines:
97 try:
98 lines = self.tail(file.name, tail_lines)
99 except IOError as err:
100 if err.errno != errno.ENOENT:
101 raise
102 else:
103 if lines:
104 self._callback(file.name, lines)
105
106 def __enter__(self):
107 return self
108
109 def __exit__(self, *args):
110 self.close()
111
112 def __del__(self):
113 self.close()
114
115 def loop(self, interval=0.1, blocking=True):
116 """Start a busy loop checking for file changes every *interval*
117 seconds. If *blocking* is False make one loop then return.
118 """
119 # May be overridden in order to use pyinotify lib and block
120 # until the directory being watched is updated.
121 # Note that directly calling readlines() as we do is faster
122 # than first checking file's last modification times.
123 while True:
124 self.update_files()
125 for fid, file in list(self._files_map.items()):
126 self.readlines(file)
127 if not blocking:
128 return
129 time.sleep(interval)
130
131 def log(self, line):
132 """Log when a file is un/watched"""
133 print(line)
134
135 def listdir(self):
136 """List directory and filter files by extension.
137 You may want to override this to add extra logic or globbing
138 support.
139 """
140 ls = os.listdir(self.folder)
141 if self.extensions:
142 ls = [x for x in ls if os.path.splitext(x)[1][1:]
143 in self.extensions]
144 if self.logfile in ls:
145 ls = [self.logfile]
146
147 return ls
148
149 @classmethod
150 def open(cls, file):
151 """Wrapper around open().
152 By default files are opened in binary mode and readlines()
153 will return bytes on both Python 2 and 3.
154 This means callback() will deal with a list of bytes.
155 Can be overridden in order to deal with unicode strings
156 instead, like this:
157
158 import codecs, locale
159 return codecs.open(file, 'r', encoding=locale.getpreferredencoding(),
160 errors='ignore')
161 """
162 return open(file, 'rb')
163
164 @classmethod
165 def tail(cls, fname, window):
166 """Read last N lines from file fname."""
167 if window <= 0:
168 raise ValueError('invalid window value %r' % window)
169 with cls.open(fname) as f:
170 buffer_size = 1024
171 # True if open() was overridden and file was opened in text
172 # mode. In that case readlines() will return unicode strings
173 # instead of bytes.
174 encoded = getattr(f, 'encoding', False)
175 CR = '\n' if encoded else b'\n'
176 data = '' if encoded else b''
177 f.seek(0, os.SEEK_END)
178 fsize = f.tell()
179 block = -1
180 exit = False
181 while not exit:
182 step = (block * buffer_size)
183 if abs(step) >= fsize:
184 f.seek(0)
185 newdata = f.read(buffer_size - (abs(step) - fsize))
186 exit = True
187 else:
188 f.seek(step, os.SEEK_END)
189 newdata = f.read(buffer_size)
190 data = newdata + data
191 if data.count(CR) >= window:
192 break
193 else:
194 block -= 1
195 return data.splitlines()[-window:]
196
197 def update_files(self):
198 ls = []
199 for name in self.listdir():
200 absname = os.path.realpath(os.path.join(self.folder, name))
201 try:
202 st = os.stat(absname)
203 except EnvironmentError as err:
204 if err.errno != errno.ENOENT:
205 raise
206 else:
207 if not stat.S_ISREG(st.st_mode):
208 continue
209 fid = self.get_file_id(st)
210 ls.append((fid, absname))
211
212 # check existent files
213 for fid, file in list(self._files_map.items()):
214 try:
215 st = os.stat(file.name)
216 except EnvironmentError as err:
217 if err.errno == errno.ENOENT:
218 self.unwatch(file, fid)
219 else:
220 raise
221 else:
222 if fid != self.get_file_id(st):
223 # same name but different file (rotation); reload it.
224 self.unwatch(file, fid)
225 self.watch(file.name)
226
227 # add new ones
228 for fid, fname in ls:
229 if fid not in self._files_map:
230 self.watch(fname)
231
232 def readlines(self, file):
233 """
234 Read file lines.
235
236 Since last access until EOF is reached and invoke callback.
237 """
238 while True:
239 lines = file.readlines(self._sizehint)
240 if not lines:
241 break
242 self._callback(file.name, lines)
243
244 def watch(self, fname):
245 try:
246 file = self.open(fname)
247 fid = self.get_file_id(os.stat(fname))
248 except EnvironmentError as err:
249 if err.errno != errno.ENOENT:
250 raise
251 else:
252 self.log("watching logfile %s" % fname)
253 self._files_map[fid] = file
254
255 def unwatch(self, file, fid):
256 # File no longer exists. If it has been renamed try to read it
257 # for the last time in case we're dealing with a rotating log
258 # file.
259 self.log("un-watching logfile %s" % file.name)
260 del self._files_map[fid]
261 with file:
262 lines = self.readlines(file)
263 if lines:
264 self._callback(file.name, lines)
265
266 @staticmethod
267 def get_file_id(st):
268 if os.name == 'posix':
269 return "%xg%x" % (st.st_dev, st.st_ino)
270 else:
271 return "%f" % st.st_ctime
272
273 def close(self):
274 for id, file in self._files_map.items():
275 file.close()
276 self._files_map.clear()
diff --git a/checkbox_support/scripts/run_watcher.py b/checkbox_support/scripts/run_watcher.py
index 9283db6..24653a5 100644
--- a/checkbox_support/scripts/run_watcher.py
+++ b/checkbox_support/scripts/run_watcher.py
@@ -1,219 +1,182 @@
1#!/usr/bin/env python31#!/usr/bin/env python3
2# Copyright 2015-2016 Canonical Ltd.2# Copyright 2015-2018 Canonical Ltd.
3# All rights reserved.3# All rights reserved.
4#4#
5# Written by:5# Written by:
6# Taihsiang Ho <taihsiang.ho@canonical.com>6# Taihsiang Ho <taihsiang.ho@canonical.com>
7# Sylvain Pineau <sylvain.pineau@canonical.com>
7"""8"""
8application to use LogWatcher.9this script monitors the systemd journal to catch insert/removal USB events
9
10this script use LogWatcher to define the actual behavior to watch log files by
11a customized callback.
12"""10"""
13import argparse11import argparse
14import contextlib12import contextlib
15import sys13import logging
16import os14import os
17import re15import re
16import select
18import signal17import signal
19import logging18import sys
20from checkbox_support.log_watcher import LogWatcher19from systemd import journal
2120
22global ARGS21
23USB_INSERT_TIMEOUT = 30 # sec22logger = logging.getLogger(__file__)
2423logger.setLevel(logging.INFO)
25# {log_string_1:status_1, log_string_2:status_2 ...}24logger.addHandler(logging.StreamHandler(sys.stdout))
26FLAG_DETECTION = {"device": {25
27 "new high-speed USB device number": False,26
28 "new SuperSpeed USB device number": False27class USBWatcher:
29 },28
30 "driver": {29 PART_RE = re.compile("sd\w+:.*(?P<part_name>sd\w+)")
31 "using ehci_hcd": False,30 USB_ACTION_TIMEOUT = 30 # sec
32 "using xhci_hcd": False31 FLAG_DETECTION = {"device": {
33 },32 "new high-speed USB device number": False,
34 "insertion": {33 "new SuperSpeed USB device number": False
35 "USB Mass Storage device detected": False
36 },34 },
37 "removal": {35 "driver": {
38 "USB disconnect, device number": False36 "using ehci_hcd": False,
37 "using xhci_hcd": False
38 },
39 "insertion": {
40 "USB Mass Storage device detected": False
41 },
42 "removal": {
43 "USB disconnect, device number": False
44 }
39 }45 }
40 }46
4147 def __init__(self, args):
4248 self.args = args
43MOUNTED_PARTITION_CANDIDATES = None49 self.MOUNTED_PARTITION = None
44MOUNTED_PARTITION = None50 signal.signal(signal.SIGALRM, self._no_usb_timeout)
4551 signal.alarm(self.USB_ACTION_TIMEOUT)
46logging.basicConfig(level=logging.WARNING)52
4753 def run(self):
4854 j = journal.Reader()
49######################################################55 j.seek_tail()
50# run the log watcher56 p = select.poll()
51######################################################57 p.register(j, j.get_events())
5258 if self.args.testcase == "insertion":
5359 print("\n\nINSERT NOW\n\n", flush=True)
54def callback(filename, lines):60 elif self.args.testcase == "removal":
55 """61 print("\n\nREMOVE NOW\n\n", flush=True)
56 a callback function for LogWatcher.62 while p.poll():
5763 if j.process() != journal.APPEND:
58 customized callback to define the actual behavior about how to watch and64 continue
59 what to watch of the log files.65 self._callback([e['MESSAGE'] for e in j if e])
6066
61 :param filename: str, a text filename. usually be a log file.67 def _callback(self, lines):
62 :param lines: list, contents the elements as string to tell what to watch.68 for line in lines:
63 """69 line_str = str(line)
64 for line in lines:70 self._refresh_detection(line_str)
65 line_str = str(line)71 self._get_partition_info(line_str)
66 refresh_detection(line_str)72 self._report_detection()
67 get_partition_info(line_str)73
68 report_detection()74 def _get_partition_info(self, line_str):
6975 """get partition info."""
7076 # looking for string like "sdb: sdb1"
71def detect_str(line, str_2_detect):77 match = re.search(self.PART_RE, line_str)
72 """detect the string in the line."""78 if match:
73 if str_2_detect in line:79 self.MOUNTED_PARTITION = match.group('part_name')
74 return True80
75 return False81 def _refresh_detection(self, line_str):
7682 """
7783 refresh values of the dictionary FLAG_DETECTION.
78def detect_partition(line):84
79 """85 :param line_str: str of the scanned log lines.
80 detect device and partition info from lines.86 """
8187 for key in self.FLAG_DETECTION.keys():
82 :param line:88 for sub_key in self.FLAG_DETECTION[key].keys():
83 str, line string from log file89 if sub_key in line_str:
8490 self.FLAG_DETECTION[key][sub_key] = True
85 :return :91
86 a list denoting [device, partition1, partition2 ...]92 def _report_detection(self):
87 from syslog93 """report detection status."""
88 """94 # insertion detection
89 # looking for string like
90 # sdb: sdb1
91 pattern = "sd.+sd.+"
92 match = re.search(pattern, line)
93 if match:
94 # remove the trailing \n and quote
95 match_string = match.group()[:-3]
96 # will looks like
97 # ['sdb', ' sdb1']
98 match_list = match_string.split(":")
99 return match_list
100
101
102def get_partition_info(line_str):
103 """get partition info."""
104 global MOUNTED_PARTITION_CANDIDATES, MOUNTED_PARTITION
105 MOUNTED_PARTITION_CANDIDIATES = detect_partition(line_str)
106 if (MOUNTED_PARTITION_CANDIDIATES and
107 len(MOUNTED_PARTITION_CANDIDIATES) == 2):
108 # hard code because I expect
109 # FLAG_MOUNT_DEVICE_CANDIDIATES is something like ['sdb', ' sdb1']
110 # This should be smarter if the device has multiple partitions.
111 MOUNTED_PARTITION = MOUNTED_PARTITION_CANDIDIATES[1].strip()
112
113
114def refresh_detection(line_str):
115 """
116 refresh values of the dictionary FLAG_DETECTION.
117
118 :param line_str: str of the scanned log lines.
119 """
120 global FLAG_DETECTION
121 for key in FLAG_DETECTION.keys():
122 for sub_key in FLAG_DETECTION[key].keys():
123 if detect_str(line_str, sub_key):
124 FLAG_DETECTION[key][sub_key] = True
125
126
127def report_detection():
128 """report detection status."""
129 # insertion detection
130 if (
131 ARGS.testcase == "insertion" and
132 FLAG_DETECTION["insertion"]["USB Mass Storage device detected"] and
133 MOUNTED_PARTITION
134 ):
135 device = ""
136 driver = ""
137 for key in FLAG_DETECTION["device"]:
138 if FLAG_DETECTION["device"][key]:
139 device = key
140 for key in FLAG_DETECTION["driver"]:
141 if FLAG_DETECTION["driver"][key]:
142 driver = key
143 logging.info("%s was inserted %s controller" % (device, driver))
144 logging.info("usable partition: %s" % MOUNTED_PARTITION)
145
146 # judge the detection by the expection
147 if (95 if (
148 ARGS.usb_type == 'usb2' and96 self.args.testcase == "insertion" and
149 device == "new high-speed USB device number"97 self.FLAG_DETECTION["insertion"]["USB Mass Storage device detected"] and
98 self.MOUNTED_PARTITION
150 ):99 ):
151 print("USB2 insertion test passed.")100 device = ""
152 write_usb_info()101 driver = ""
153 sys.exit()102 for key in self.FLAG_DETECTION["device"]:
103 if self.FLAG_DETECTION["device"][key]:
104 device = key
105 for key in self.FLAG_DETECTION["driver"]:
106 if self.FLAG_DETECTION["driver"][key]:
107 driver = key
108 logger.info("%s was inserted %s controller" % (device, driver))
109 logger.info("usable partition: %s" % self.MOUNTED_PARTITION)
110 # judge the detection by the expection
111 if (
112 self.args.usb_type == 'usb2' and
113 device == "new high-speed USB device number"
114 ):
115 logger.info("USB2 insertion test passed.")
116 self._write_usb_info()
117 sys.exit()
118 if (
119 self.args.usb_type == 'usb3' and
120 device == "new SuperSpeed USB device number"
121 ):
122 logger.info("USB3 insertion test passed.")
123 self._write_usb_info()
124 sys.exit()
125 # removal detection
154 if (126 if (
155 ARGS.usb_type == 'usb3' and127 self.args.testcase == "removal" and
156 device == "new SuperSpeed USB device number"128 self.FLAG_DETECTION["removal"]["USB disconnect, device number"]
157 ):129 ):
158 print("USB3 insertion test passed.")130 logger.info("Removal test passed.")
159 write_usb_info()131 self._remove_usb_info()
160 sys.exit()132 sys.exit()
161133
162 # removal detection134 def _write_usb_info(self):
163 if (135 """
164 ARGS.testcase == "removal" and136 reserve detected usb storage info.
165 FLAG_DETECTION["removal"]["USB disconnect, device number"]137
166 ):138 write the info we got in this script to $PLAINBOX_SESSION_SHARE
167 logging.info("An USB mass storage was removed.")139 so the other jobs, e.g. read/write test, could know more information,
168 remove_usb_info()140 for example the partition it want to try to mount.
169 sys.exit()141 """
170142 plainbox_session_share = os.environ.get('PLAINBOX_SESSION_SHARE')
171143 if not plainbox_session_share:
172def write_usb_info():144 logger.error("no env var PLAINBOX_SESSION_SHARE")
173 """145 sys.exit(1)
174 reserve detected usb storage info.146 if self.MOUNTED_PARTITION:
175147 logger.info(
176 write the info we got in this script to $PLAINBOX_SESSION_SHARE148 "cache file usb_insert_info is at: %s"
177 so the other jobs, e.g. read/write test, could know more information,149 % plainbox_session_share)
178 for example the partition it want to try to mount.150 file_to_share = open(
179 """151 os.path.join(plainbox_session_share, "usb_insert_info"), "w")
180 plainbox_session_share = os.environ.get('PLAINBOX_SESSION_SHARE')152 file_to_share.write(self.MOUNTED_PARTITION + "\n")
181 if not plainbox_session_share:153 file_to_share.close()
182 logging.warning("no env var PLAINBOX_SESSION_SHARE")154
155 def _remove_usb_info(self):
156 """remove usb strage info from $PLAINBOX_SESSION_SHARE."""
157 plainbox_session_share = os.environ.get('PLAINBOX_SESSION_SHARE')
158 if not plainbox_session_share:
159 logger.error("no env var PLAINBOX_SESSION_SHARE")
160 sys.exit(1)
161 file_to_share = os.path.join(
162 plainbox_session_share, "usb_insert_info")
163 with contextlib.suppress(FileNotFoundError):
164 os.remove(file_to_share)
165
166 def _no_usb_timeout(self, signum, frame):
167 """
168 define timeout feature.
169
170 timeout and return failure if there is no usb insertion/removal
171 detected after USB_ACTION_TIMEOUT secs
172 """
173 logger.error(
174 "no USB storage %s was reported in systemd journal"
175 % self.args.testcase)
183 sys.exit(1)176 sys.exit(1)
184177
185 if MOUNTED_PARTITION:
186 logging.info(
187 "cache file usb_insert_info is at: %s" % plainbox_session_share)
188 file_to_share = open(
189 os.path.join(plainbox_session_share, "usb_insert_info"), "w")
190 file_to_share.write(MOUNTED_PARTITION + "\n")
191 file_to_share.close()
192
193
194def remove_usb_info():
195 """remove usb strage info from $PLAINBOX_SESSION_SHARE."""
196 plainbox_session_share = os.environ.get('PLAINBOX_SESSION_SHARE')
197 if not plainbox_session_share:
198 logging.warning("no env var PLAINBOX_SESSION_SHARE")
199 sys.exit(1)
200 file_to_share = os.path.join(plainbox_session_share, "usb_insert_info")
201 with contextlib.suppress(FileNotFoundError):
202 os.remove(file_to_share)
203
204
205def no_usb_timeout(signum, frame):
206 """
207 define timeout feature.
208
209 timeout and return failure if there is no usb insertion is detected
210 after USB_INSERT_TIMEOUT secs
211 """
212 logging.info("no USB storage insertion was detected from /var/log/syslog")
213 sys.exit(1)
214178
215def main():179def main():
216 # access the parser
217 parser = argparse.ArgumentParser()180 parser = argparse.ArgumentParser()
218 parser.add_argument('testcase',181 parser.add_argument('testcase',
219 choices=['insertion', 'removal'],182 choices=['insertion', 'removal'],
@@ -221,18 +184,9 @@ def main():
221 parser.add_argument('usb_type',184 parser.add_argument('usb_type',
222 choices=['usb2', 'usb3'],185 choices=['usb2', 'usb3'],
223 help=("usb2 or usb3"))186 help=("usb2 or usb3"))
224 global ARGS187 args = parser.parse_args()
225 ARGS = parser.parse_args()188 watcher = USBWatcher(args)
226189 watcher.run()
227 # set up the log watcher
228 watcher = LogWatcher("/var/log", callback, logfile="syslog")
229 signal.signal(signal.SIGALRM, no_usb_timeout)
230 signal.alarm(USB_INSERT_TIMEOUT)
231 if ARGS.testcase == "insertion":
232 print("\n\nINSERT NOW\n\n", flush=True)
233 elif ARGS.testcase == "removal":
234 print("\n\nREMOVE NOW\n\n", flush=True)
235 watcher.loop()
236190
237191
238if __name__ == "__main__":192if __name__ == "__main__":

Subscribers

People subscribed via source and target branches