quarchpy 2.2.17.dev2__py3-none-any.whl → 2.2.17.dev3__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.
quarchpy/__init__.py CHANGED
@@ -6,16 +6,34 @@ import logging
6
6
  from pathlib import Path
7
7
  from logging.handlers import RotatingFileHandler
8
8
 
9
+
10
+ # --- Dynamic Level Synchronization ---
11
+ class SyncWithRootFilter(logging.Filter):
12
+ """
13
+ A filter that dynamically checks the root logger's level.
14
+ This allows the quarchpy console handler to 'point' to the
15
+ global python log level even if it changes mid-script.
16
+ """
17
+
18
+ def filter(self, record):
19
+ # Dynamically fetch the root logger's effective level
20
+ root_level = logging.getLogger().getEffectiveLevel()
21
+ # Only allow the log record if its level is >= the current root level
22
+ return record.levelno >= root_level
23
+
24
+
9
25
  logger = logging.getLogger("quarchpy")
26
+
27
+ # Master Valve: Must stay DEBUG so the File Handler captures everything.
10
28
  logger.setLevel(logging.DEBUG)
11
29
 
12
- # Don't propagate to root unless you want duplicate output
30
+ # Propagate to root is False because we provide our own console handler.
31
+ # If True, you would see duplicate logs if the user also has a root handler.
13
32
  logger.propagate = False
14
33
 
15
34
  # Only add handlers once
16
35
  if not logger.handlers:
17
-
18
- # File handler (always debug)
36
+ # 1. File handler (Always capture DEBUG for the 'permanent record')
19
37
  log_dir = Path.home() / ".quarchpy"
20
38
  log_dir.mkdir(exist_ok=True)
21
39
  logfile = log_dir / "quarchpy.log"
@@ -28,9 +46,15 @@ if not logger.handlers:
28
46
  ))
29
47
  logger.addHandler(file_handler)
30
48
 
31
- # Console: default WARNING
49
+ # 2. Console: Dynamically mirrors the python root log level
32
50
  console_handler = logging.StreamHandler()
33
- console_handler.setLevel(logging.WARNING)
51
+
52
+ # We set this to NOTSET (0) so the handler itself doesn't block anything...
53
+ console_handler.setLevel(logging.NOTSET)
54
+
55
+ # ...instead, we use the custom filter to do the dynamic level check.
56
+ console_handler.addFilter(SyncWithRootFilter())
57
+
34
58
  console_handler.setFormatter(logging.Formatter(
35
59
  "%(asctime)s [%(levelname)s] %(name)s: %(message)s",
36
60
  "%Y-%m-%d %H:%M:%S"
@@ -42,9 +66,14 @@ if not logger.handlers:
42
66
  def configure_logging(console_level=None, file_level=None, file_path=None):
43
67
  """Reconfigure quarchpy logging safely."""
44
68
  for handler in logger.handlers:
45
- # Change console level
46
- if isinstance(handler, logging.StreamHandler):
69
+ # Change console level (Note: Setting this overrides the SyncWithRootFilter behavior)
70
+ if isinstance(handler, logging.StreamHandler) and not isinstance(handler, RotatingFileHandler):
47
71
  if console_level is not None:
72
+ # If the user explicitly sets a level, we remove the dynamic filter
73
+ # so it behaves exactly as the user requested.
74
+ for f in handler.filters:
75
+ if isinstance(f, SyncWithRootFilter):
76
+ handler.removeFilter(f)
48
77
  handler.setLevel(console_level)
49
78
 
50
79
  # Change file level and path
@@ -54,61 +83,40 @@ def configure_logging(console_level=None, file_level=None, file_path=None):
54
83
  if file_path is not None:
55
84
  handler.baseFilename = str(file_path)
56
85
 
57
- logger.info("quarchpy logging reconfigured")
58
-
59
-
60
- # Adds / to the path.
61
- folder2add = os.path.realpath(os.path.abspath(os.path.split(inspect.getfile( inspect.currentframe() ))[0]) + "//")
62
- if folder2add not in sys.path:
63
- sys.path.insert(0, folder2add)
64
-
65
- # Adds /disk_test to the path.
66
- folder2add = os.path.realpath(os.path.abspath(os.path.split(inspect.getfile( inspect.currentframe() ))[0]) + "//disk_test")
67
- if folder2add not in sys.path:
68
- sys.path.insert(0, folder2add)
86
+ logger.info(
87
+ f"quarchpy logging reconfigured console_level:{console_level} file_level:{file_level} file_path:{file_path}")
69
88
 
70
- # Adds /connection_specific to the path.
71
- folder2add = os.path.realpath(os.path.abspath(os.path.split(inspect.getfile( inspect.currentframe() ))[0]) + "//connection_specific")
72
- if folder2add not in sys.path:
73
- sys.path.insert(0, folder2add)
74
89
 
75
- # Adds /serial to the path.
76
- folder2add = os.path.realpath(os.path.abspath(os.path.split(inspect.getfile( inspect.currentframe() ))[0]) + "//connection_specific//serial")
77
- if folder2add not in sys.path:
78
- sys.path.insert(0, folder2add)
90
+ # --- Path Management (Maintain existing legacy behavior) ---
91
+ base_path = os.path.dirname(os.path.abspath(inspect.getfile(inspect.currentframe())))
92
+ folders = [
93
+ "",
94
+ "disk_test",
95
+ "connection_specific",
96
+ os.path.join("connection_specific", "serial"),
97
+ os.path.join("connection_specific", "QIS"),
98
+ os.path.join("connection_specific", "usb_libs"),
99
+ "config_files"
100
+ ]
79
101
 
80
- # Adds /QIS to the path.
81
- folder2add = os.path.realpath(os.path.abspath(os.path.split(inspect.getfile( inspect.currentframe() ))[0]) + "//connection_specific//QIS")
82
- if folder2add not in sys.path:
83
- sys.path.insert(0, folder2add)
102
+ for folder in folders:
103
+ folder2add = os.path.realpath(os.path.join(base_path, folder))
104
+ if folder2add not in sys.path:
105
+ sys.path.insert(0, folder2add)
84
106
 
85
- # Adds /usb_libs to the path.
86
- folder2add = os.path.realpath(os.path.abspath(os.path.split(inspect.getfile( inspect.currentframe() ))[0]) + "//connection_specific//usb_libs")
87
- if folder2add not in sys.path:
88
- sys.path.insert(0, folder2add)
89
-
90
- # Adds /usb_libs to the path.
91
- folder2add = os.path.realpath(os.path.abspath(os.path.split(inspect.getfile( inspect.currentframe() ))[0]) + "//config_files")
92
- if folder2add not in sys.path:
93
- sys.path.insert(0, folder2add)
94
-
95
- # Basic functions imported up to the root module in the package
107
+ # --- Public API Imports ---
96
108
  from debug.versionCompare import requiredQuarchpyVersion
97
-
98
- #importing legacy API functions to the root module in the package. This is to avoid
99
- #breacking back-compatibility with old scripts. Avoid using these direct imports
100
- #and use the managed sub module format instead (from quarchpy.device import *)
101
109
  from device import quarchDevice, getQuarchDevice, get_quarch_device
102
110
  from connection_specific.connection_QIS import QisInterface, QisInterface as qisInterface
103
111
  from connection_specific.connection_QPS import QpsInterface, QpsInterface as qpsInterface
104
112
  from qis.qisFuncs import isQisRunning, startLocalQis, GetQisModuleSelection
105
113
  from qis.qisFuncs import closeQis, closeQis as closeQIS
106
114
  from device.quarchPPM import quarchPPM
107
- from iometer.iometerFuncs import generateIcfFromCsvLineData, readIcfCsvLineData, generateIcfFromConf, runIOMeter, processIometerInstResults
115
+ from iometer.iometerFuncs import generateIcfFromCsvLineData, readIcfCsvLineData, generateIcfFromConf, runIOMeter, \
116
+ processIometerInstResults
108
117
  from device.quarchQPS import quarchQPS
109
118
  from qps.qpsFuncs import isQpsRunning, startLocalQps, GetQpsModuleSelection
110
119
  from qps.qpsFuncs import closeQps, closeQps as closeQPS
111
120
  from disk_test.DiskTargetSelection import getDiskTargetSelection, getDiskTargetSelection as GetDiskTargetSelection
112
121
  from fio.FIO_interface import runFIO
113
- from device.scanDevices import scanDevices
114
-
122
+ from device.scanDevices import scanDevices
quarchpy/_version.py CHANGED
@@ -1 +1 @@
1
- __version__ = "2.2.17.dev2"
1
+ __version__ = "2.2.17.dev3"
@@ -609,8 +609,7 @@ class QisInterface:
609
609
  logger.warning(e)
610
610
  return False
611
611
 
612
-
613
- def get_qis_module_selection(self, preferred_connection_only=True, additional_options="DEF_ARGS", scan=True) -> str:
612
+ def get_qis_module_selection(self, preferred_connection_only=True, additional_options=['rescan', 'all con types', 'ip scan'], scan=True) -> str:
614
613
  """
615
614
  Scans for available modules and allows the user to select one through an interactive selection process.
616
615
  Can also handle additional custom options and some built-in ones such as rescanning
@@ -634,10 +633,6 @@ class QisInterface:
634
633
  ValueError: Raised if no valid selection is made or the provided IP address is invalid.
635
634
  """
636
635
 
637
- # Avoid mutable warning by adding the argument list in the function rather than the header
638
- if additional_options == "DEF_ARGS":
639
- additional_options = ['rescan', 'all con types', 'ip scan']
640
-
641
636
  table_headers = ["Modules"]
642
637
  ip_address = None
643
638
  favourite = preferred_connection_only
@@ -650,8 +645,8 @@ class QisInterface:
650
645
  found_devices = self.qis_scan_devices(scan=scan, preferred_connection_only=favourite, ip_address=ip_address)
651
646
 
652
647
  my_device_id = listSelection(title="Select a module",message="Select a module",
653
- selectionList=found_devices, additionalOptions= additional_options,
654
- nice=True, tableHeaders=table_headers, indexReq=True)
648
+ selectionList=found_devices, additionalOptions= additional_options,
649
+ nice=True, tableHeaders=table_headers, indexReq=True)
655
650
 
656
651
  if my_device_id.lower() == 'rescan':
657
652
  favourite = True
@@ -1,15 +1,13 @@
1
1
  import sys
2
2
  import socket
3
- import time
4
- import datetime
5
- import subprocess
6
- import os
7
- import random
8
3
  import logging
9
- logger = logging.getLogger(__name__)
10
4
  import time
11
5
  import re
12
- from quarchpy.user_interface import user_interface
6
+ from typing import Union, List, Optional, Any
7
+
8
+ from quarchpy.user_interface import user_interface, User_interface, printText, listSelection, requestDialog
9
+
10
+ logger = logging.getLogger(__name__)
13
11
 
14
12
  class QpsInterface:
15
13
  def __init__(self, host='127.0.0.1', port=9822):
@@ -113,7 +111,6 @@ class QpsInterface:
113
111
  time.sleep(0.3)
114
112
  return retVal
115
113
 
116
-
117
114
  def disconnect(self, targetDevice):
118
115
  self.sendCmdVerbose("$disconnect")
119
116
 
@@ -122,6 +119,7 @@ class QpsInterface:
122
119
  return self.sendCmdVerbose("close")
123
120
  else:
124
121
  return self.sendCmdVerbose(conString+" close")
122
+
125
123
  def scanIP(self, ipAddress, sleep=10):
126
124
  """
127
125
  Triggers QPS to look at a specific IP address for a quarch module
@@ -195,6 +193,71 @@ class QpsInterface:
195
193
  #return list of devices
196
194
  return deviceList
197
195
 
196
+ def get_qps_module_selection(
197
+ self,
198
+ preferred_connection_only: bool = True,
199
+ additional_options: Optional[List[str]] = None,
200
+ scan: bool = True
201
+ ) -> Optional[Any]:
202
+ """
203
+ Scans for QPS devices and prompts the user to select one.
204
+ """
205
+ if additional_options is None:
206
+ additional_options = ['rescan', 'all con types', 'ip scan']
207
+
208
+ # State variables
209
+ favourite = preferred_connection_only
210
+ ip_address = None
211
+
212
+ while True:
213
+ printText("QPS scanning for devices")
214
+
215
+ # 1. Fetch raw list from QPS
216
+ dev_list = self.getDeviceList(scan=scan, ipAddress=ip_address)
217
+
218
+ # 2. Check for empty results and sanitize
219
+ # If no devices found, force favorite mode off to prevent sorting bugs
220
+ if self._is_list_empty_or_error(dev_list):
221
+ favourite = False
222
+
223
+ # Remove REST devices (unsupported here)
224
+ dev_list = [x for x in dev_list if "rest" not in x]
225
+
226
+ # 3. Apply Sorting
227
+ # We always sort by type (USB > TCP), but we only deduplicate
228
+ # (hide extra connections) if 'favourite' is True.
229
+ dev_list = self._apply_favourite_sorting(dev_list, one_conn_per_mod=favourite)
230
+
231
+ # 4. Show UI
232
+ selection = listSelection(
233
+ title="Select a Quarch module",
234
+ message="Select a Quarch module",
235
+ selectionList=dev_list,
236
+ additionalOptions=additional_options,
237
+ nice=True,
238
+ tableHeaders=["Module"],
239
+ indexReq=True
240
+ )
241
+
242
+ # 5. Handle User Response
243
+ if selection == 'rescan':
244
+ ip_address = None
245
+ favourite = True
246
+ continue
247
+
248
+ elif selection == 'all con types':
249
+ printText('Displaying all connection types...')
250
+ favourite = False
251
+ continue
252
+
253
+ elif selection == 'ip scan':
254
+ ip_address = requestDialog("Please input IP Address of the module you would like to connect to: ")
255
+ favourite = False
256
+ continue
257
+
258
+ else:
259
+ # If it's not an option, it must be a device
260
+ return selection
198
261
 
199
262
  def open_recording(self, file_path, cmdTimeout=5, pollInterval=3, startOpenTimout=5):
200
263
  """
@@ -239,5 +302,50 @@ class QpsInterface:
239
302
  time.sleep(1) # sleep outside the loop as there is a
240
303
  return message
241
304
 
305
+ def _is_list_empty_or_error(self, dev_list: List[str]) -> bool:
306
+ """Checks if the returned list is empty or contains error messages."""
307
+ if not dev_list:
308
+ return True
309
+ first_item = dev_list[0].lower()
310
+ return "no device" in first_item or "no module" in first_item
311
+
312
+ def _apply_favourite_sorting(self, dev_list: List[str], one_conn_per_mod: bool = True) -> List[str]:
313
+ """
314
+ Sorts devices by connection preference (USB > TCP > ...).
315
+ If one_conn_per_mod is True, it also removes duplicate physical devices,
316
+ keeping only the highest priority connection type found.
317
+ """
318
+ sorted_list = []
319
+ seen_ids = set()
320
+ con_pref = ["USB", "TCP", "SERIAL", "REST", "TELNET"]
321
+
322
+ # Helper to process a device and decide if it should be added
323
+ def try_add_device(device_str):
324
+ if device_str in sorted_list:
325
+ return
326
+
327
+ if one_conn_per_mod:
328
+ # Extract ID (e.g., 'QTL1234' from 'USB::QTL1234')
329
+ if "::" in device_str:
330
+ dev_id = device_str.split("::")[1]
331
+ if dev_id in seen_ids:
332
+ return # Skip: We already have a preferred connection for this ID
333
+ seen_ids.add(dev_id)
334
+
335
+ sorted_list.append(device_str)
336
+
337
+ # Pass 1: Add devices that match our specific preferences, in order
338
+ for pref in con_pref:
339
+ for device in dev_list:
340
+ if pref in device.upper():
341
+ try_add_device(device)
342
+
343
+ # Pass 2: Add any remaining devices that didn't match preferences (Safety net)
344
+ # This ensures devices with weird/new connection types don't disappear.
345
+ for device in dev_list:
346
+ if device not in sorted_list:
347
+ try_add_device(device)
348
+
349
+ return sorted_list
242
350
 
243
351
 
@@ -1,7 +1,21 @@
1
- from telnetlib import Telnet
1
+ import logging
2
2
  import time
3
- #from telnetlib3 import Telnet
3
+ import sys
4
4
 
5
+ # -----------------------------------------------------------
6
+ # Import Logic: Handles Python 3.13+ deprecation of telnetlib
7
+ # -----------------------------------------------------------
8
+ try:
9
+ from telnetlib import Telnet
10
+ except ImportError:
11
+ logging.debug("Standard telnetlib not found (Python 3.13+ detected). Using drop-in replacement.")
12
+ try:
13
+ from telnetlib313andup import Telnet
14
+ except ImportError as e:
15
+ logging.error("CRITICAL: 'telnetlib313andup' is not installed.")
16
+ logging.error("Please run: pip install telnetlib-313-and-up")
17
+ raise e
18
+ # -----------------------------------------------------------
5
19
 
6
20
  class TelnetConn:
7
21
  def __init__(self, ConnTarget):
@@ -14,14 +28,14 @@ class TelnetConn:
14
28
  self.Connection.close()
15
29
  # The closed device reports as in use if a connection is opened to it within 0.05s.
16
30
  # This happens during scanning as rest detects the device and shows it as "in use" Putting a sleep here
17
- # allows time for the connection to be close,
31
+ # allows time for the connection to be close,
18
32
  time.sleep(0.15)
19
33
  return True
20
34
 
21
35
  def sendCommand(self, Command, expectedResponse = True):
22
36
  self.Connection.write((Command + "\r\n").encode('latin-1'))
23
- self.Connection.read_until(b"\r\n",3)
24
- Result = self.Connection.read_until(b">",3)[:-1]
37
+ self.Connection.read_until(b"\r\n", 3)
38
+ Result = self.Connection.read_until(b">", 3)[:-1]
25
39
  Result = Result.decode()
26
40
  Result = Result.strip('> \t\n\r')
27
41
  return Result.strip()
@@ -11,11 +11,8 @@ def main():
11
11
  current_os = platform.system()
12
12
  if current_os != "Windows":
13
13
  print("Fixing Permissions")
14
- # Check the current OS
15
- current_os = platform.system()
16
14
  # JRE path
17
15
  java_path = quarchpy_binaries.get_jre_home()
18
-
19
16
  # Ensure the jres folder has the required permissions
20
17
  subprocess.call(['chmod', '-R', '+rwx', java_path])
21
18
 
@@ -48,6 +48,7 @@ import ctypes.util
48
48
  import platform
49
49
  import os.path
50
50
  import sys
51
+ import libusb_package
51
52
 
52
53
  class Enum(object):
53
54
  def __init__(self, member_dict, scope_dict=None):
@@ -170,6 +171,26 @@ def _loadLibrary():
170
171
  if sys.version_info[:2] >= (2, 6):
171
172
  loader_kw['use_errno'] = True
172
173
  loader_kw['use_last_error'] = True
174
+
175
+ try:
176
+ bundled_path = libusb_package.find_library("usb-1.0")
177
+
178
+ # If that failed (macOS/Linux), find the file manually
179
+ import os
180
+ # Get the folder where libus_package is located
181
+ wrapper_dir = os.path.dirname(libusb_package.__file__)
182
+
183
+ # Look for the dylib/so in that folder
184
+ potential_path = os.path.join(wrapper_dir, "libusb-1.0" + suffix)
185
+
186
+ if os.path.exists(potential_path):
187
+ bundled_path = potential_path
188
+
189
+ if bundled_path:
190
+ return dll_loader(bundled_path, **loader_kw)
191
+ except Exception:
192
+ pass # If bundled fails, proceed to system search below
193
+
173
194
  try:
174
195
  return dll_loader('libusb-1.0' + suffix, **loader_kw)
175
196
  except OSError:
@@ -236,24 +236,82 @@ def _get_logging_directory(app_type: AppType = "QuarchPy") -> str:
236
236
 
237
237
  return log_dir
238
238
 
239
+
240
+ import os
241
+ import sys
242
+ import platform
243
+
244
+
239
245
  def fix_usb():
246
+ print("\n--- USB Permission Setup ---")
247
+
248
+ rule_file_path = "/etc/udev/rules.d/20-quarchmodules.rules"
249
+
250
+ # --- CHECK EXISTING INSTALLATION ---
251
+ # We check if the file exists AND if it contains the correct settings
252
+ if os.path.exists(rule_file_path):
253
+ try:
254
+ with open(rule_file_path, 'r') as f:
255
+ content = f.read()
256
+ # We look for the Vendor ID (16d0) and the writable mode (0666)
257
+ if 'idVendor' in content and '16d0' in content and '0666' in content:
258
+ print("USB permissions appear to be correctly set up already. No changes needed.")
259
+ return # Exit the function here
260
+ except IOError:
261
+ # If we can't read the file for some reason, we assume we need to fix it
262
+ pass
263
+
264
+ # --- IF WE REACH HERE, PERMISSIONS ARE MISSING ---
265
+
266
+ print("This script needs to update your system permissions to allow access to the device.")
267
+ print(f"We will add a rule file to: {rule_file_path}")
268
+
269
+ # Link provided before any action is taken
270
+ print("\nFor full details on why this is required, please read:")
271
+ print("https://quarch.com/support/faqs/usb/#linux-permissions")
272
+ print("----------------------------")
273
+
274
+ # Ask for permission before proceeding
275
+ response = input("Do you want to proceed with these changes? (y/n): ")
276
+ if response.lower() != 'y':
277
+ print("Operation cancelled by user.")
278
+ return
279
+
280
+ # Check if the user is root (UID 0)
281
+ print("\nStep 1: Checking for administrator privileges...")
282
+ if os.geteuid() != 0:
283
+ print(">> Current user is not admin. Elevating permissions now...")
284
+
285
+ # Re-run the script with sudo
286
+ args = ['sudo', sys.executable] + sys.argv
287
+ os.execvp('sudo', args)
288
+
289
+ # If we reach here, we are root
290
+ print(">> Administrator privileges confirmed.")
291
+
292
+ print("Step 2: Defining the access rules (Vendor ID: 16d0)...")
240
293
  content_to_write = "SUBSYSTEM==\"usb\", ATTRS{idVendor}==\"16d0\", MODE=\"0666\"\n" \
241
294
  "SUBSYSTEM==\"usb_device\", ATTRS{idVendor}==\"16d0\", MODE=\"0666\""
242
295
 
243
296
  if "centos" in str(platform.platform()).lower():
297
+ print(">> CentOS detected. Applying group permissions fix...")
244
298
  content_to_write = "SUBSYSTEM==\"usb\", ATTRS{idVendor}==\"16d0\", MODE=\"0666\", GROUP=*\n " \
245
299
  "SUBSYSTEM==\"usb_device\", ATTRS{idVendor}==\"16d0\", MODE=\"0666\", GROUP=*"
246
300
 
247
- destination = "/etc/udev/rules.d/20-quarchmodules.rules"
248
-
249
- f = open("/etc/udev/rules.d/20-quarchmodules.rules", "w")
250
- f.write(content_to_write)
251
- f.close()
252
-
301
+ print("Step 3: Saving the rule file...")
302
+ try:
303
+ f = open(rule_file_path, "w")
304
+ f.write(content_to_write)
305
+ f.close()
306
+ except IOError as e:
307
+ print(f"!! Failed to write file: {e}")
308
+ return
309
+
310
+ print("Step 4: Reloading system USB drivers...")
253
311
  os.system("udevadm control --reload")
254
312
  os.system("udevadm trigger")
255
313
 
256
- print("USB rule added to file : /etc/udev/rules.d/20-quarchmodules.rules")
314
+ print("\nSUCCESS: USB Setup Complete.")
257
315
 
258
316
  def _check_fw():
259
317
  print("")
@@ -76,15 +76,23 @@ def main(filepath=None):
76
76
  except FileNotFoundError as err:
77
77
  logger.error(f"Config file not found for module : {moduleStr}\nExiting Script")
78
78
  my_device.close_connection()
79
- return
79
+ return None
80
80
 
81
81
  # Parse the file to get the device capabilities
82
- dev_caps = parse_config_file(file)
82
+ try:
83
+ dev_caps = parse_config_file(file)
84
+ except Exception as err:
85
+ logger.error(f"Could not parse config file for {moduleStr}\nExiting Script"
86
+ f"\nError details: {err}")
87
+ print(f"Could not parse config file for {moduleStr}\nPlease note only breaker and hot-plug modules are currently supported."
88
+ f"\nExiting Script")
89
+ my_device.close_connection()
90
+ return None
83
91
 
84
92
  if not dev_caps:
85
93
  logger.error(f"Could not parse config file for {moduleStr}\nExiting Script")
86
94
  my_device.close_connection()
87
- return
95
+ return None
88
96
  logText("\nCONFIG FILE LOCATED:")
89
97
  logText(file)
90
98
  logText("\n")