cubevis 1.0.0__py3-none-any.whl → 1.0.4__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.
Files changed (46) hide show
  1. cubevis/__init__.py +12 -23
  2. cubevis/__js__/bokeh-3.6/cubevisjs.min.js +64 -0
  3. cubevis/__js__/{cubevisjs.min.js → bokeh-3.7/cubevisjs.min.js} +3 -3
  4. cubevis/__js__/bokeh-3.8/cubevisjs.min.js +64 -0
  5. cubevis/__js__/casalib.min.js +1 -1
  6. cubevis/__version__.py +1 -1
  7. cubevis/bokeh/__init__.py +16 -0
  8. cubevis/bokeh/annotations/_ev_poly_annotation.py +2 -1
  9. cubevis/bokeh/format/_wcs_ticks.py +6 -2
  10. cubevis/bokeh/models/__init__.py +1 -0
  11. cubevis/bokeh/models/_showable.py +352 -0
  12. cubevis/bokeh/models/_tip.py +2 -1
  13. cubevis/bokeh/models/_tip_button.py +2 -3
  14. cubevis/bokeh/sources/_data_pipe.py +6 -2
  15. cubevis/bokeh/sources/_image_data_source.py +6 -2
  16. cubevis/bokeh/sources/_image_pipe.py +4 -1
  17. cubevis/bokeh/sources/_spectra_data_source.py +6 -3
  18. cubevis/bokeh/sources/_updatable_data_source.py +6 -2
  19. cubevis/bokeh/state/__init__.py +4 -3
  20. cubevis/bokeh/state/_current.py +34 -0
  21. cubevis/bokeh/state/_initialize.py +282 -116
  22. cubevis/bokeh/state/_javascript.py +95 -21
  23. cubevis/bokeh/tools/_cbreset_tool.py +2 -1
  24. cubevis/bokeh/tools/_drag_tool.py +2 -1
  25. cubevis/bokeh/utils/__init__.py +0 -1
  26. cubevis/exe/_setting.py +1 -0
  27. cubevis/private/apps/__init__.py +4 -2
  28. cubevis/private/apps/_interactiveclean.mustache +6 -2
  29. cubevis/private/apps/_interactiveclean.py +6 -2
  30. cubevis/private/apps/_interactivecleannotebook.mustache +112 -0
  31. cubevis/private/apps/_interactivecleannotebook.py +1874 -0
  32. cubevis/private/casatasks/iclean.py +4 -0
  33. cubevis/toolbox/_app_context.py +5 -9
  34. cubevis/toolbox/_cube.py +6 -2
  35. cubevis/toolbox/_interactive_clean_ui.mustache +20 -31
  36. cubevis/toolbox/_interactive_clean_ui.py +20 -31
  37. cubevis/utils/__init__.py +183 -41
  38. cubevis/utils/_git.py +36 -0
  39. cubevis/utils/_jupyter.py +12 -0
  40. {cubevis-1.0.0.dist-info → cubevis-1.0.4.dist-info}/METADATA +3 -3
  41. {cubevis-1.0.0.dist-info → cubevis-1.0.4.dist-info}/RECORD +43 -39
  42. cubevis/__js__/bokeh-3.6.1.min.js +0 -728
  43. cubevis/__js__/bokeh-tables-3.6.1.min.js +0 -119
  44. cubevis/__js__/bokeh-widgets-3.6.1.min.js +0 -141
  45. {cubevis-1.0.0.dist-info → cubevis-1.0.4.dist-info}/WHEEL +0 -0
  46. {cubevis-1.0.0.dist-info → cubevis-1.0.4.dist-info}/licenses/LICENSE +0 -0
@@ -1828,4 +1828,8 @@ class _iclean:
1828
1828
  task_result = _end_log( _logging_state_, 'iclean', task_result )
1829
1829
  return task_result
1830
1830
 
1831
+ def notebook( self, *args, **kwargs ):
1832
+ from .iclean_notebook import iclean_notebook
1833
+ return iclean_notebook( *args, **kwargs )
1834
+
1831
1835
  iclean = _iclean( )
@@ -1,6 +1,6 @@
1
1
  ########################################################################
2
2
  #
3
- # Copyright (C) 2024
3
+ # Copyright (C) 2024,2025
4
4
  # Associated Universities, Inc. Washington DC, USA.
5
5
  #
6
6
  # This script is free software; you can redistribute it and/or modify it
@@ -25,13 +25,14 @@
25
25
  # Charlottesville, VA 22903-2475 USA
26
26
  #
27
27
  ########################################################################
28
- from cubevis.bokeh.state import initialize_bokeh
29
28
  from tempfile import TemporaryDirectory
30
29
  from bokeh.io import output_file
31
30
  from os.path import join
32
31
  import unicodedata
33
32
  import re
34
33
 
34
+ from ..utils import is_interactive_jupyter
35
+
35
36
  class AppContext:
36
37
 
37
38
  def _slugify(self, value, allow_unicode=False):
@@ -53,18 +54,13 @@ class AppContext:
53
54
 
54
55
  def __init__( self, title, prefix=None, init_bokeh=True ):
55
56
 
56
- ###
57
- ### Setup up Bokeh paths, inject cubevis libraries into Bokeh HTML output
58
- ###
59
- if init_bokeh:
60
- initialize_bokeh( )
61
-
62
57
  if prefix is None:
63
58
  ## create a prefix from the title
64
59
  prefix = self._slugify(title)[:10]
65
60
  self.__workdir = TemporaryDirectory(prefix=prefix)
66
61
  self.__htmlpath = join( self.__workdir.name, f'''{self._slugify(title)}.html''' )
67
- output_file( self.__htmlpath, title=title )
62
+ if not is_interactive_jupyter( ):
63
+ output_file( self.__htmlpath, title=title )
68
64
 
69
65
  def __del__( self ):
70
66
  ### remove work directory and its contents
cubevis/toolbox/_cube.py CHANGED
@@ -100,7 +100,7 @@ class CubeMask:
100
100
  self.COUNT = 1
101
101
  self.CCOUNT = 1
102
102
 
103
- self._is_notebook = is_interactive_jupyter()
103
+ ##self._is_notebook = is_interactive_jupyter()
104
104
  #self._color = '#00FF00' # anti-green user feedback (issue #40 2024-05-02 13:08:32)
105
105
  self._region_style=dict( fill_alpha=0, hover_fill_alpha=0.3,
106
106
  fill_color='white', hover_fill_color='white',
@@ -1505,7 +1505,11 @@ class CubeMask:
1505
1505
  function done_close_window( msg ) {
1506
1506
  if ( msg.result === 'stopped' ) {""" +
1507
1507
  # Don't close tab if running in a jupyter notebook
1508
- ("""console.log("Running in jupyter notebook. Not closing window.")""" if self._is_notebook else
1508
+ ##("""console.log("Running in jupyter notebook. Not closing window.")""" if self._is_notebook else
1509
+ ## """console.log("Running from script/terminal. Closing window.")
1510
+ ## window.close()"""
1511
+ ##) +
1512
+ ("""console.log("Running in jupyter notebook. Not closing window.")""" if is_interactive_jupyter( ) else
1509
1513
  """console.log("Running from script/terminal. Closing window.")
1510
1514
  window.close()"""
1511
1515
  ) +
@@ -29,15 +29,6 @@
29
29
  '''implementation of the ``InteractiveCleanUI`` application for interactive control
30
30
  of tclean'''
31
31
 
32
- ###
33
- ### Useful for debugging
34
- ###
35
- ###from cubevis.bokeh.state import initialize_bokeh
36
- ###initialize_bokeh( bokehjs_subst=".../bokeh-3.2.2.js" )
37
- ###
38
-
39
- from pprint import pprint
40
-
41
32
  import os
42
33
  import sys
43
34
  import copy
@@ -137,8 +128,8 @@ class InteractiveCleanUI:
137
128
  ports.append( imdetails['gui']['cube']._pipe['control'].address[1] )
138
129
 
139
130
  # Also forward http port if serving webpage
140
- if not self._is_notebook:
141
- ports.append(self._http_port)
131
+ #if not self._is_notebook:
132
+ # ports.append(self._http_port)
142
133
 
143
134
  cmd = 'ssh'
144
135
  for port in ports:
@@ -187,7 +178,7 @@ class InteractiveCleanUI:
187
178
  ###
188
179
  ### whether or not the session is being run from a jupyter notebook or script
189
180
  ###
190
- self._is_notebook = is_interactive_jupyter()
181
+ #self._is_notebook = is_interactive_jupyter()
191
182
 
192
183
  ##
193
184
  ## the http port for serving GUI in webpage if not running in script
@@ -520,7 +511,7 @@ class InteractiveCleanUI:
520
511
  'flux_axis': flux_axis
521
512
  }
522
513
 
523
- def _launch_gui( self ):
514
+ def _build_bokeh( self ):
524
515
  '''create and show GUI
525
516
  '''
526
517
  ###
@@ -625,7 +616,7 @@ class InteractiveCleanUI:
625
616
  icw = SharedWidgets( )
626
617
  toolbars = [ ]
627
618
  for imid, imdetails in self._clean_targets.items( ):
628
- imdetails['gui']['stats'] = imdetails['gui']['cube'].statistics( )
619
+ imdetails['gui']['stats'] = imdetails['gui']['cube'].statistics( name=f"{imid} stats" )
629
620
  imdetails['image-channels'] = imdetails['gui']['cube'].shape( )[3]
630
621
 
631
622
  status_line = imdetails['gui']['stopcode'] = imdetails['gui']['cube'].status_text( "<p>initial residual image</p>" if imdetails['image-channels'] > 1 else "<p>initial <b>single-channel</b> residual image</p>", width=230, reuse=status_line )
@@ -969,15 +960,15 @@ class InteractiveCleanUI:
969
960
  itergroups[document._casa_image_name].active = document._casa_last_control_tab''' ) )
970
961
 
971
962
  # Change display type depending on runtime environment
972
- if self._is_notebook:
973
- output_notebook()
974
- else:
975
- ### Directory is created when an HTTP server is running
976
- ### (MAX)
977
- ### output_file(self._imagename+'_webpage/index.html')
978
- pass
963
+ #if self._is_notebook:
964
+ # output_notebook()
965
+ #else:
966
+ # ### Directory is created when an HTTP server is running
967
+ # ### (MAX)
968
+ ### # output_file(self._imagename+'_webpage/index.html')
969
+ # pass
979
970
 
980
- show(self._fig['layout'])
971
+ return self._fig['layout']
981
972
 
982
973
  def _create_colormap_adjust( self, imdetails ):
983
974
  palette = imdetails['gui']['cube'].palette( reuse=self._cube_palette )
@@ -1015,7 +1006,7 @@ class InteractiveCleanUI:
1015
1006
  title='Colormap' ),
1016
1007
  TabPanel( child=column( *imdetails['gui']['stats'] ),
1017
1008
  title='Statistics' ) ] + imdetails['gui']['auto-masking-panel'],
1018
- width=500, sizing_mode='stretch_height', tabs_location='below' )
1009
+ sizing_mode='stretch_height', tabs_location='below' )
1019
1010
 
1020
1011
  if not hasattr(self,'_image_control_tab_groups'):
1021
1012
  self._image_control_tab_groups = { }
@@ -1046,7 +1037,7 @@ class InteractiveCleanUI:
1046
1037
  height_policy='max', width_policy='max' ),
1047
1038
  height_policy='max', width_policy='max' ), title=imid )
1048
1039
 
1049
- def __call__( self, setting, exec_context, id=None ):
1040
+ def __call__( self, exec_context, task_id=None ):
1050
1041
  '''Display GUI and process events until the user stops the application.
1051
1042
 
1052
1043
  Example:
@@ -1075,13 +1066,12 @@ class InteractiveCleanUI:
1075
1066
 
1076
1067
  ###
1077
1068
  ### cubevis.exe subpkg supports adding a stop condition to allow for interrupt,
1078
- ### but it is not needed for synchronous execution
1069
+ ### but it is not needed for synchronous execution, e.g.
1070
+ ### self._exec['stop-condition'], self._exec['id'] = exec_context.create_stop_condition(task_id)
1079
1071
  ###
1080
1072
  self._exec = { 'stop-condition': None }
1081
- #self._exec['stop-condition'], self._exec['id'] = exec_context.create_stop_condition(id)
1082
1073
 
1083
- return exe.Task( self._task_server )
1084
- # , stop_condition=self._exec['stop-condition'] )
1074
+ return self._build_bokeh( ), exe.Task( self._task_server )
1085
1075
 
1086
1076
  async def _task_server( self ):
1087
1077
  """Wrapper for your serve() context manager"""
@@ -1131,8 +1121,6 @@ class InteractiveCleanUI:
1131
1121
 
1132
1122
  httpd.serve_forever()
1133
1123
 
1134
- self._launch_gui( )
1135
-
1136
1124
  async with CMC( *( [ ctx for img in self._clean_targets.keys( ) for ctx in
1137
1125
  [
1138
1126
  self._clean_targets[img]['gui']['cube'].serve(self.__stop),
@@ -1296,6 +1284,7 @@ class InteractiveCleanUI:
1296
1284
  ### -- document is used storing state --
1297
1285
  ### --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- ---
1298
1286
  'initialize': '''if ( ! document._casa_initialized ) {
1287
+ console.log(`casalib version: ${casalib.version}`)
1299
1288
  document._casa_image_name = initial_image
1300
1289
  document._casa_initialized = true
1301
1290
  document._casa_window_closed = false
@@ -1303,7 +1292,7 @@ class InteractiveCleanUI:
1303
1292
  function (e) {
1304
1293
  // if the window is already closed this message is never
1305
1294
  // delivered (unless interactive clean is called again then
1306
- // the event shows up in the newly created control pipe
1295
+ // the event shows up in the newly created control pipe)
1307
1296
  if ( document._casa_window_closed == false ) {
1308
1297
  ctrl_pipe.send( ids['stop'],
1309
1298
  { action: 'stop', value: { } },
@@ -28,15 +28,6 @@
28
28
  '''implementation of the ``InteractiveCleanUI`` application for interactive control
29
29
  of tclean'''
30
30
 
31
- ###
32
- ### Useful for debugging
33
- ###
34
- ###from cubevis.bokeh.state import initialize_bokeh
35
- ###initialize_bokeh( bokehjs_subst=".../bokeh-3.2.2.js" )
36
- ###
37
-
38
- from pprint import pprint
39
-
40
31
  import os
41
32
  import sys
42
33
  import copy
@@ -136,8 +127,8 @@ class InteractiveCleanUI:
136
127
  ports.append( imdetails['gui']['cube']._pipe['control'].address[1] )
137
128
 
138
129
  # Also forward http port if serving webpage
139
- if not self._is_notebook:
140
- ports.append(self._http_port)
130
+ #if not self._is_notebook:
131
+ # ports.append(self._http_port)
141
132
 
142
133
  cmd = 'ssh'
143
134
  for port in ports:
@@ -186,7 +177,7 @@ class InteractiveCleanUI:
186
177
  ###
187
178
  ### whether or not the session is being run from a jupyter notebook or script
188
179
  ###
189
- self._is_notebook = is_interactive_jupyter()
180
+ #self._is_notebook = is_interactive_jupyter()
190
181
 
191
182
  ##
192
183
  ## the http port for serving GUI in webpage if not running in script
@@ -519,7 +510,7 @@ class InteractiveCleanUI:
519
510
  'flux_axis': flux_axis
520
511
  }
521
512
 
522
- def _launch_gui( self ):
513
+ def _build_bokeh( self ):
523
514
  '''create and show GUI
524
515
  '''
525
516
  ###
@@ -624,7 +615,7 @@ class InteractiveCleanUI:
624
615
  icw = SharedWidgets( )
625
616
  toolbars = [ ]
626
617
  for imid, imdetails in self._clean_targets.items( ):
627
- imdetails['gui']['stats'] = imdetails['gui']['cube'].statistics( )
618
+ imdetails['gui']['stats'] = imdetails['gui']['cube'].statistics( name=f"{imid} stats" )
628
619
  imdetails['image-channels'] = imdetails['gui']['cube'].shape( )[3]
629
620
 
630
621
  status_line = imdetails['gui']['stopcode'] = imdetails['gui']['cube'].status_text( "<p>initial residual image</p>" if imdetails['image-channels'] > 1 else "<p>initial <b>single-channel</b> residual image</p>", width=230, reuse=status_line )
@@ -968,15 +959,15 @@ class InteractiveCleanUI:
968
959
  itergroups[document._casa_image_name].active = document._casa_last_control_tab''' ) )
969
960
 
970
961
  # Change display type depending on runtime environment
971
- if self._is_notebook:
972
- output_notebook()
973
- else:
974
- ### Directory is created when an HTTP server is running
975
- ### (MAX)
976
- ### output_file(self._imagename+'_webpage/index.html')
977
- pass
962
+ #if self._is_notebook:
963
+ # output_notebook()
964
+ #else:
965
+ # ### Directory is created when an HTTP server is running
966
+ # ### (MAX)
967
+ ### # output_file(self._imagename+'_webpage/index.html')
968
+ # pass
978
969
 
979
- show(self._fig['layout'])
970
+ return self._fig['layout']
980
971
 
981
972
  def _create_colormap_adjust( self, imdetails ):
982
973
  palette = imdetails['gui']['cube'].palette( reuse=self._cube_palette )
@@ -1014,7 +1005,7 @@ class InteractiveCleanUI:
1014
1005
  title='Colormap' ),
1015
1006
  TabPanel( child=column( *imdetails['gui']['stats'] ),
1016
1007
  title='Statistics' ) ] + imdetails['gui']['auto-masking-panel'],
1017
- width=500, sizing_mode='stretch_height', tabs_location='below' )
1008
+ sizing_mode='stretch_height', tabs_location='below' )
1018
1009
 
1019
1010
  if not hasattr(self,'_image_control_tab_groups'):
1020
1011
  self._image_control_tab_groups = { }
@@ -1045,7 +1036,7 @@ class InteractiveCleanUI:
1045
1036
  height_policy='max', width_policy='max' ),
1046
1037
  height_policy='max', width_policy='max' ), title=imid )
1047
1038
 
1048
- def __call__( self, setting, exec_context, id=None ):
1039
+ def __call__( self, exec_context, task_id=None ):
1049
1040
  '''Display GUI and process events until the user stops the application.
1050
1041
 
1051
1042
  Example:
@@ -1074,13 +1065,12 @@ class InteractiveCleanUI:
1074
1065
 
1075
1066
  ###
1076
1067
  ### cubevis.exe subpkg supports adding a stop condition to allow for interrupt,
1077
- ### but it is not needed for synchronous execution
1068
+ ### but it is not needed for synchronous execution, e.g.
1069
+ ### self._exec['stop-condition'], self._exec['id'] = exec_context.create_stop_condition(task_id)
1078
1070
  ###
1079
1071
  self._exec = { 'stop-condition': None }
1080
- #self._exec['stop-condition'], self._exec['id'] = exec_context.create_stop_condition(id)
1081
1072
 
1082
- return exe.Task( self._task_server )
1083
- # , stop_condition=self._exec['stop-condition'] )
1073
+ return self._build_bokeh( ), exe.Task( self._task_server )
1084
1074
 
1085
1075
  async def _task_server( self ):
1086
1076
  """Wrapper for your serve() context manager"""
@@ -1130,8 +1120,6 @@ class InteractiveCleanUI:
1130
1120
 
1131
1121
  httpd.serve_forever()
1132
1122
 
1133
- self._launch_gui( )
1134
-
1135
1123
  async with CMC( *( [ ctx for img in self._clean_targets.keys( ) for ctx in
1136
1124
  [
1137
1125
  self._clean_targets[img]['gui']['cube'].serve(self.__stop),
@@ -1295,6 +1283,7 @@ class InteractiveCleanUI:
1295
1283
  ### -- document is used storing state --
1296
1284
  ### --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- ---
1297
1285
  'initialize': '''if ( ! document._casa_initialized ) {
1286
+ console.log(`casalib version: ${casalib.version}`)
1298
1287
  document._casa_image_name = initial_image
1299
1288
  document._casa_initialized = true
1300
1289
  document._casa_window_closed = false
@@ -1302,7 +1291,7 @@ class InteractiveCleanUI:
1302
1291
  function (e) {
1303
1292
  // if the window is already closed this message is never
1304
1293
  // delivered (unless interactive clean is called again then
1305
- // the event shows up in the newly created control pipe
1294
+ // the event shows up in the newly created control pipe)
1306
1295
  if ( document._casa_window_closed == false ) {
1307
1296
  ctrl_pipe.send( ids['stop'],
1308
1297
  { action: 'stop', value: { } },
cubevis/utils/__init__.py CHANGED
@@ -45,6 +45,7 @@ from ._copydoc import copydoc
45
45
  from ._pkgs import find_pkg, load_pkg
46
46
  from ._jupyter import is_interactive_jupyter
47
47
  from ._browser import have_firefox
48
+ from ._git import max_git_version
48
49
 
49
50
  from astropy import units
50
51
  from regions import PixCoord
@@ -103,29 +104,69 @@ def path_to_url(path):
103
104
  else:
104
105
  return path
105
106
 
106
-
107
- def find_ws_address(address='127.0.0.1'):
108
- '''Find free port on ``address`` network and return a tuple with ``address`` and port number
109
-
110
- This function uses the low level socket function to find a free port and return
111
- a tuple representing the address plus port number.
112
-
113
- Parameters
114
- ----------
115
- address: str
116
- network to be probed for an available port
117
-
118
- Returns
119
- -------
120
- tuple of str and int
121
- network address (`str`) and port number (`int`)
122
- '''
123
- sock = socket()
124
- sock.bind((address, 0))
125
- result = sock.getsockname()
126
- sock.close()
127
- return result
128
-
107
+ _DEBUG_NOTEBOOK_REUSE_ = False
108
+ if _DEBUG_NOTEBOOK_REUSE_:
109
+ def find_ws_address(address='127.0.0.1'):
110
+ '''Find free port on ``address`` network and return a tuple with ``address`` and port number
111
+
112
+ This function uses the low level socket function to find a free port and return
113
+ a tuple representing the address plus port number.
114
+
115
+ Parameters
116
+ ----------
117
+ address: str
118
+ network to be probed for an available port
119
+
120
+ Returns
121
+ -------
122
+ tuple of str and int
123
+ network address (`str`) and port number (`int`)
124
+ '''
125
+ sock = socket()
126
+ sock.bind((address, 0))
127
+ result = sock.getsockname()
128
+ sock.close()
129
+ return result
130
+ else:
131
+
132
+ _returned_ports = set()
133
+
134
+ def find_ws_address(address='127.0.0.1'):
135
+ '''Find free port on ``address`` network and return a tuple with ``address`` and port number
136
+
137
+ This function finds a free port that hasn't been returned before in this session.
138
+ It keeps sockets open during the search to guarantee uniqueness, then closes them
139
+ before returning.
140
+
141
+ Parameters
142
+ ----------
143
+ address: str
144
+ network to be probed for an available port
145
+
146
+ Returns
147
+ -------
148
+ tuple of str and int
149
+ network address (`str`) and port number (`int`)
150
+ '''
151
+ opened_sockets = []
152
+ try:
153
+ max_attempts = 2500
154
+ for _ in range(max_attempts):
155
+ sock = socket()
156
+ sock.bind((address, 0))
157
+ opened_sockets.append(sock)
158
+ result = sock.getsockname()
159
+ port = result[1]
160
+
161
+ if port not in _returned_ports:
162
+ _returned_ports.add(port)
163
+ return result
164
+
165
+ raise RuntimeError(f"Could not find unused port after {max_attempts} attempts")
166
+ finally:
167
+ # Always close all opened sockets
168
+ for sock in opened_sockets:
169
+ sock.close()
129
170
 
130
171
  def partition(pred, iterable):
131
172
  '''Split ``iterable`` into two lists based on ``pred`` predicate.
@@ -167,28 +208,129 @@ def warn_import(package):
167
208
  error_msg("warning, %s" % warn_import.msgs[package].format(package=package))
168
209
 
169
210
 
170
- @static_vars(url='http://clients3.google.com/generate_204')
211
+ @static_vars(url='http://clients3.google.com/generate_204',
212
+ cached_result=None,
213
+ cache_timeout=30) # Cache result for 30 seconds
171
214
  def have_network():
172
- '''check to see if an active network with general internet connectivity
173
- is available. returns ``True`` if we have internet connectivity and
215
+ '''Check to see if an active network with general internet connectivity
216
+ is available. Returns ``True`` if we have internet connectivity and
174
217
  ``False`` if we do not.
218
+
219
+ Uses Google's connectivity check endpoint and caches the result briefly
220
+ to avoid repeated network calls.
175
221
  '''
176
- ###
177
- ### see: https://stackoverflow.com/questions/50558000/test-internet-connection-for-python3
178
- ###
179
- try:
180
- with urllib.request.urlopen(have_network.url) as response:
181
- return response.status == 204
182
- except urllib.error.HTTPError:
183
- ### http error
184
- return False
185
- except urllib.error.ContentTooShortError:
186
- return False
187
- except urllib.error.URLError:
188
- return False
189
- except Exception:
190
- return False
222
+ import time
223
+
224
+ # Check cache first
225
+ current_time = time.time()
226
+ if (hasattr(have_network, 'last_check_time') and
227
+ have_network.cached_result is not None and
228
+ current_time - have_network.last_check_time < have_network.cache_timeout):
229
+ return have_network.cached_result
230
+
231
+ def check_connectivity():
232
+ try:
233
+ # Set a reasonable timeout to avoid hanging
234
+ request = urllib.request.Request(
235
+ have_network.url,
236
+ headers={'User-Agent': 'Mozilla/5.0 (compatible; connectivity-check)'}
237
+ )
238
+
239
+ with urllib.request.urlopen(request, timeout=5) as response:
240
+ return response.status == 204
241
+
242
+ except urllib.error.HTTPError as e:
243
+ # Some proxies might return different status codes but still have connectivity
244
+ if e.code in [200, 301, 302]:
245
+ return True
246
+ return False
247
+ except (urllib.error.ContentTooShortError,
248
+ urllib.error.URLError,
249
+ socket.timeout,
250
+ socket.gaierror, # DNS resolution errors
251
+ OSError) as e:
252
+ return False
253
+ except Exception:
254
+ # Catch any other unexpected errors
255
+ return False
256
+
257
+ # Perform the check
258
+ result = check_connectivity()
259
+
260
+ # Cache the result
261
+ have_network.cached_result = result
262
+ have_network.last_check_time = current_time
191
263
 
264
+ return result
265
+
266
+ # Alternative version with fallback URLs
267
+ @static_vars(urls=['http://clients3.google.com/generate_204',
268
+ 'http://detectportal.firefox.com/success.txt',
269
+ 'http://www.msftconnecttest.com/connecttest.txt'],
270
+ cached_result=None,
271
+ cache_timeout=30)
272
+ def have_network_with_fallback():
273
+ '''Check network connectivity using multiple fallback endpoints.
274
+
275
+ Tries multiple well-known connectivity check endpoints to increase
276
+ reliability in different network environments.
277
+ '''
278
+ import time
279
+
280
+ # Check cache first
281
+ current_time = time.time()
282
+ if (hasattr(have_network_with_fallback, 'last_check_time') and
283
+ have_network_with_fallback.cached_result is not None and
284
+ current_time - have_network_with_fallback.last_check_time < have_network_with_fallback.cache_timeout):
285
+ return have_network_with_fallback.cached_result
286
+
287
+ def try_url(url, expected_status=None, expected_content=None):
288
+ try:
289
+ request = urllib.request.Request(
290
+ url,
291
+ headers={'User-Agent': 'Mozilla/5.0 (compatible; connectivity-check)'}
292
+ )
293
+
294
+ with urllib.request.urlopen(request, timeout=5) as response:
295
+ if expected_status and response.status != expected_status:
296
+ return False
297
+
298
+ if expected_content:
299
+ content = response.read().decode('utf-8').strip()
300
+ return content == expected_content
301
+
302
+ # For Google's endpoint, expect 204 No Content
303
+ if 'generate_204' in url:
304
+ return response.status == 204
305
+
306
+ # For other endpoints, any successful response is good
307
+ return 200 <= response.status < 400
308
+
309
+ except Exception:
310
+ return False
311
+
312
+ # Try each URL until one succeeds
313
+ for url in have_network_with_fallback.urls:
314
+ if url == 'http://detectportal.firefox.com/success.txt':
315
+ if try_url(url, expected_content='success'):
316
+ result = True
317
+ break
318
+ elif url == 'http://www.msftconnecttest.com/connecttest.txt':
319
+ if try_url(url, expected_content='Microsoft Connect Test'):
320
+ result = True
321
+ break
322
+ else: # Google's endpoint
323
+ if try_url(url):
324
+ result = True
325
+ break
326
+ else:
327
+ result = False
328
+
329
+ # Cache the result
330
+ have_network_with_fallback.cached_result = result
331
+ have_network_with_fallback.last_check_time = current_time
332
+
333
+ return result
192
334
 
193
335
  def ranges(iterable, order=sorted, key=lambda x: x):
194
336
  '''collect elements of ``iterable`` into tuple ranges where each tuple represents
cubevis/utils/_git.py ADDED
@@ -0,0 +1,36 @@
1
+ import subprocess
2
+ from os import path
3
+ from typing import Optional
4
+ from packaging import version
5
+
6
+ _VERSION = None
7
+ def max_git_version() -> Optional[str]:
8
+ """Get maximum version from all git tags"""
9
+ global _VERSION
10
+ package_parent = path.dirname(path.dirname(path.dirname(path.realpath(__file__))))
11
+ if _VERSION is None and path.basename( package_parent ) == 'cubevis' and \
12
+ path.isdir(path.join( package_parent, '.git' )):
13
+ result = subprocess.run(
14
+ ['git', 'tag'],
15
+ cwd=package_parent,
16
+ capture_output=True,
17
+ text=True,
18
+ check=True
19
+ )
20
+
21
+ # Filter tags matching version pattern v#.#.#
22
+ tags = [
23
+ tag[1:] for tag in result.stdout.strip().split('\n')
24
+ if tag.startswith('v') and all(part.isdigit() for part in tag[1:].split('.'))
25
+ ]
26
+
27
+ if not tags:
28
+ return None
29
+
30
+ # Sort by version number and get maximum
31
+ try:
32
+ max_tag = max(tags, key=lambda v: version.parse(v))
33
+ _VERSION = max_tag
34
+ except: pass
35
+
36
+ return _VERSION