Coverage for .tox/py312/lib/python3.12/site-packages/pydalec/cli.py: 81%

104 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-04 16:06 +0200

1"""Command-line interface for connecting to a DALEC instrument over TCP.""" 

2 

3from __future__ import annotations 

4 

5import argparse 

6import sys 

7import time 

8from collections.abc import Sequence 

9 

10from pydalec.errors import ( 

11 PyDalecConnectionError, 

12 PyDalecNoPositionDataError, 

13 PyDalecNoSolarZenithDataError, 

14) 

15from pydalec.instrument import Dalec, Location 

16from pydalec.logging_utils import enable_debug_logging 

17 

18 

19class _CliArgumentError(ValueError): 

20 """Raised when parsed CLI arguments violate local validation rules.""" 

21 

22 

23def _parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: 

24 """Parse CLI arguments for TCP connection and stream polling settings.""" 

25 parser = argparse.ArgumentParser( 

26 prog='pydalec-test', 

27 description='Connect to a DALEC instrument over TCP and stream measurements.', 

28 ) 

29 parser.add_argument('ip', help='Instrument IP address') 

30 parser.add_argument('--port', type=int, default=23, help='TCP port (default: 23)') 

31 parser.add_argument( 

32 '--data-root-dir', 

33 type=str, 

34 default=None, 

35 help='Root directory where incoming data files are stored', 

36 ) 

37 parser.add_argument( 

38 '--max-file-size-kb', 

39 type=int, 

40 default=51200, 

41 help='Maximum size in kB for each output file (default: 51200, i.e. 50 MB)', 

42 ) 

43 parser.add_argument( 

44 '--poll-interval', 

45 type=float, 

46 default=0.2, 

47 help='Polling interval in seconds for new measurements (default: 0.2)', 

48 ) 

49 parser.add_argument( 

50 '--debug', 

51 action='store_true', 

52 help='Enable pydalec debug logging (without configuring the root logger)', 

53 ) 

54 return parser.parse_args(argv) 

55 

56 

57def _print_new_measurements(client: Dalec, last_measurement: object | None) -> object | None: 

58 """Print newly received measurements and return the latest seen record.""" 

59 measurements = client.measurement_log 

60 if not measurements: 

61 return last_measurement 

62 

63 start_index = 0 

64 if last_measurement is not None: 

65 for index, measurement in enumerate(measurements): 

66 if measurement == last_measurement: 

67 start_index = index + 1 

68 

69 new_measurements = measurements[start_index:] 

70 for measurement in new_measurements: 

71 print(measurement) 

72 

73 return measurements[-1] 

74 

75 

76def print_header(client: Dalec) -> None: 

77 """Print the current location and sun zenith before streaming.""" 

78 try: 

79 location: Location = client.get_location() 

80 solar_zenith_deg: float = client.get_solar_zenith() 

81 print(f'Current location: {location}') 

82 print(f'Current sun zenith: {solar_zenith_deg} deg') 

83 except PyDalecNoPositionDataError: 

84 print('ERROR: No location data available') 

85 except PyDalecNoSolarZenithDataError: 

86 print('ERROR: No sun zenith data available') 

87 else: 

88 _ = input('Press Enter to start streaming measurements...') 

89 

90 

91def _get_and_check_args(argv: Sequence[str] | None = None) -> argparse.Namespace: 

92 """Parse CLI arguments and check for required dependencies.""" 

93 args = _parse_args(argv) 

94 raw_argv = list(argv) if argv is not None else sys.argv[1:] 

95 

96 if args.data_root_dir is None and '--max-file-size-kb' in raw_argv: 

97 err_msg = '--max-file-size-kb requires --data-root-dir.' 

98 raise _CliArgumentError(err_msg) 

99 

100 return args 

101 

102 

103def _get_args_or_exit_code(argv: Sequence[str] | None = None) -> argparse.Namespace | int: 

104 """Return parsed CLI arguments or an exit code for invalid invocation.""" 

105 try: 

106 return _get_and_check_args(argv) 

107 except _CliArgumentError as exc: 

108 print(exc, file=sys.stderr) 

109 return 2 

110 except SystemExit as exc: 

111 if exc.code in (None, 0): 

112 return 0 

113 if isinstance(exc.code, int): 

114 return exc.code 

115 print(exc.code, file=sys.stderr) 

116 return 2 

117 

118 

119def run(argv: Sequence[str] | None = None) -> int: 

120 """Run the CLI workflow and return a shell-style exit code.""" 

121 args = _get_args_or_exit_code(argv) 

122 if isinstance(args, int): 

123 return args 

124 

125 if args.debug: 

126 enable_debug_logging() 

127 

128 try: 

129 client = Dalec.connect_tcp( 

130 args.ip, 

131 args.port, 

132 data_root_dir=args.data_root_dir, 

133 max_file_size_kb=args.max_file_size_kb, 

134 ) 

135 except PyDalecConnectionError as exc: 

136 print(f'Connection failed: {exc}', file=sys.stderr) 

137 return 1 

138 except ValueError as exc: 

139 print(f'Invalid configuration: {exc}', file=sys.stderr) 

140 return 2 

141 

142 print(f'Connected to DALEC at {args.ip}:{args.port}') 

143 print_header(client) 

144 

145 exit_code = make_measurements(args, client) 

146 

147 return exit_code 

148 

149 

150def make_measurements(args: argparse.Namespace, client: Dalec) -> int: 

151 """Stream measurements until interrupted and return an exit code.""" 

152 last_measurement = None 

153 exit_code = 0 

154 measurements_started = False 

155 try: 

156 client.start_measurements() 

157 measurements_started = True 

158 print('Started measurements. Press Ctrl+C to stop.') 

159 

160 while True: 

161 last_measurement = _print_new_measurements(client, last_measurement) 

162 time.sleep(args.poll_interval) 

163 except KeyboardInterrupt: 

164 print('\nStopping measurement stream...') 

165 except Exception as exc: # pragma: no cover 

166 print(f'Runtime error: {exc}', file=sys.stderr) 

167 exit_code = 1 

168 finally: 

169 if measurements_started: 

170 try: 

171 client.stop_measurements() 

172 except Exception as exc: # pragma: no cover 

173 print(f'Failed to stop measurements cleanly: {exc}', file=sys.stderr) 

174 exit_code = 1 

175 

176 try: 

177 client.disconnect() 

178 except Exception as exc: # pragma: no cover 

179 print(f'Failed to disconnect cleanly: {exc}', file=sys.stderr) 

180 exit_code = 1 

181 

182 print('Disconnected.') 

183 return exit_code 

184 

185 

186def main(argv: Sequence[str] | None = None) -> None: 

187 """Run the CLI and exit the process with the returned status code.""" 

188 raise SystemExit(run(argv)) 

189 

190 

191if __name__ == '__main__': 

192 main()