1111import signal
1212import urllib .parse
1313import requests
14+ import tempfile
1415from pathlib import Path
1516from argparse import ArgumentParser , Namespace
1617from typing import Dict , Any , Optional
@@ -61,6 +62,10 @@ def run(self, args):
6162 print (" Example: circremote /dev/ttyUSB0 ./my_sensor.py" )
6263 print (" Example: circremote /dev/ttyUSB0 /path/to/sensor/directory" )
6364 print (" Example: circremote /dev/ttyUSB0 ../custom_sensors/BME280" )
65+ print ("Remote commands:" )
66+ print (" Example: circremote /dev/ttyUSB0 https://github.com/user/repo/sensor/" )
67+ print (" Example: circremote /dev/ttyUSB0 https://example.com/sensor.py" )
68+ print (" Example: circremote /dev/ttyUSB0 https://raw.githubusercontent.com/user/repo/main/sensor.py" )
6469 print ("Use -h for more options" )
6570 print ("Use -h COMMAND for help on a specific command" )
6671 sys .exit (1 )
@@ -71,25 +76,29 @@ def run(self, args):
7176 # Check if command_name looks like a URL and fetch content if so
7277 file_content = None
7378 info_data = None
79+ requirements_content = None
80+ is_remote_directory = False
7481
82+ # Check if command_name looks like a URL and fetch content if so
7583 if self .looks_like_url (command_name ):
7684 self .debug (f"Command '{ command_name } ' looks like a URL, fetching content" , options )
7785 try :
78- file_content = self .fetch_url_content (command_name , options )
86+ file_content , info_data , requirements_content , is_remote_directory = self .fetch_remote_command (command_name , options )
7987 if file_content :
8088 self .debug (f"Fetched { len (file_content .encode ('utf-8' ))} bytes from URL: { command_name } " , options )
8189 # For URL commands, we'll skip the local file processing
8290 command_dir = None
8391 code_file = None
92+ is_pathname = False # URL commands are not pathnames
8493 else :
8594 print (f"Failed to fetch content from URL: { command_name } " )
8695 sys .exit (1 )
8796 except Exception as e :
8897 print (f"Error fetching URL content: { e } " )
8998 sys .exit (1 )
90-
91- # Resolve command path and get file_content, command_dir, code_file, info_data
92- file_content , command_dir , code_file , info_data , is_pathname = self .resolve_command_path (command_name , options )
99+ else :
100+ # Only resolve command path if it's not a URL
101+ file_content , command_dir , code_file , info_data , is_pathname = self .resolve_command_path (command_name , options )
93102
94103 # If it's not a pathname, check for command aliases and built-in commands
95104 if not is_pathname :
@@ -102,7 +111,7 @@ def run(self, args):
102111 if self .looks_like_url (command_name ):
103112 self .debug (f"Aliased command '{ command_name } ' looks like a URL, fetching content" , options )
104113 try :
105- file_content = self .fetch_url_content (command_name , options )
114+ file_content , info_data , requirements_content , is_remote_directory = self .fetch_remote_command (command_name , options )
106115 if file_content :
107116 self .debug (f"Fetched { len (file_content .encode ('utf-8' ))} bytes from aliased URL: { command_name } " , options )
108117 # For URL commands, we'll skip the local file processing
@@ -191,7 +200,7 @@ def run(self, args):
191200 # Store remaining arguments for later parsing after we have info.json
192201 remaining_args = remaining [2 :]
193202
194- # Check for requirements.txt and install dependencies with circup (only for local commands)
203+ # Check for requirements.txt and install dependencies with circup (for local commands)
195204 if not file_content and command_dir : # Only for local commands
196205 requirements_file = command_dir / 'requirements.txt'
197206 self .debug ("Checking for requirements.txt in command directory" , options )
@@ -217,6 +226,26 @@ def run(self, args):
217226 else :
218227 self .debug ("requirements.txt has no actual content (only comments/blanks), skipping circup" , options )
219228
229+ # Check for remote requirements.txt and install dependencies with circup (for remote commands)
230+ if requirements_content and not options .skip_circup :
231+ self .debug ("Remote requirements.txt found, checking content" , options )
232+
233+ # Filter out comments and blank lines to check for actual requirements
234+ actual_requirements = [
235+ line .strip () for line in requirements_content .split ('\n ' )
236+ if line .strip () and not line .strip ().startswith ('#' )
237+ ]
238+
239+ self .debug (f"Remote requirements content: { repr (requirements_content )} " , options )
240+ self .debug (f"Actual requirements after filtering: { actual_requirements } " , options )
241+ self .debug (f"Number of actual requirements: { len (actual_requirements )} " , options )
242+
243+ if actual_requirements :
244+ self .debug ("Remote requirements.txt has actual content, checking for circup" , options )
245+ self .handle_remote_circup_installation (requirements_content , serial_port , password , options )
246+ else :
247+ self .debug ("Remote requirements.txt has no actual content, skipping circup" , options )
248+
220249 # Read and parse info.json file (only for local commands)
221250 if not file_content and command_dir : # Only for local commands
222251 info_file = command_dir / 'info.json'
@@ -1206,6 +1235,93 @@ def run_circup_command(self, full_command, options):
12061235 print ("Continuing anyway..." )
12071236 print ()
12081237
1238+ def handle_remote_circup_installation (self , requirements_content , serial_port , password , options ):
1239+ """Handle circup dependency installation for remote commands."""
1240+ # Get circup path from config with precedence handling
1241+ circup_path = self .config .get_circup_path ()
1242+
1243+ # Check if the specified circup path exists and is executable
1244+ if not os .path .exists (circup_path ) or not os .access (circup_path , os .X_OK ):
1245+ print (f"⚠️ Warning: requirements.txt found but circup not found or not executable at: { circup_path } " )
1246+ print (" Please install circup to automatically install dependencies:" )
1247+ print (" pip install circup" )
1248+ print (" Or specify the correct path with -c PATH or in config file" )
1249+ print (" Continuing without installing dependencies..." )
1250+ print ()
1251+ return
1252+
1253+ self .debug (f"Found circup at: { circup_path } " , options )
1254+
1255+ # Create a temporary requirements file
1256+ import tempfile
1257+ with tempfile .NamedTemporaryFile (mode = 'w' , suffix = '.txt' , delete = False ) as temp_file :
1258+ temp_file .write (requirements_content )
1259+ temp_requirements_path = temp_file .name
1260+
1261+ try :
1262+ # Build circup command
1263+ circup_args = []
1264+ # Add WebSocket-specific arguments if using WebSocket
1265+ if re .match (r'^(\d{1,3}\.){3}\d{1,3}(:\d+)?$' , serial_port ):
1266+ self .debug ("Detected WebSocket connection, adding host/port/password args" , options )
1267+ if ':' in serial_port :
1268+ host , port = serial_port .split (':' , 1 )
1269+ else :
1270+ host = serial_port
1271+ port = "80" # Default port if not specified
1272+
1273+ circup_args += ["--host" , host , "--port" , port ]
1274+ if password :
1275+ circup_args += ["--password" , password ]
1276+
1277+ circup_args += ["install" , "-r" , temp_requirements_path ]
1278+ full_command = [circup_path ] + circup_args
1279+ command_string = " " .join (full_command )
1280+
1281+ print ("\n " + "=" * 60 )
1282+ print ("📦 REMOTE DEPENDENCIES FOUND" )
1283+ print ("=" * 60 )
1284+ print ()
1285+ print ("This remote module requires CircuitPython libraries to be installed." )
1286+ print ("Requirements content:" )
1287+ print ("-" * 40 )
1288+ print (requirements_content .strip ())
1289+ print ("-" * 40 )
1290+ print ()
1291+ print ("The following command will be executed:" )
1292+ print (f" { command_string } " )
1293+ print ()
1294+ print ("Options:" )
1295+ print (" r - run (install dependencies and continue)" )
1296+ print (" s - skip (continue without installing dependencies)" )
1297+ print (" x - exit (cancel operation)" )
1298+ print ()
1299+
1300+ if options .yes :
1301+ print ("Installing dependencies automatically due to -y flag..." )
1302+ self .run_circup_command (full_command , options )
1303+ else :
1304+ response = input ("What would you like to do? (r/s/x): " ).strip ().lower ()
1305+
1306+ if response in ['r' , 'run' ]:
1307+ print ("Installing dependencies..." )
1308+ self .run_circup_command (full_command , options )
1309+ elif response in ['s' , 'skip' ]:
1310+ print ("Skipping dependency installation..." )
1311+ print ()
1312+ elif response in ['x' , 'exit' ]:
1313+ print ("Operation cancelled by user." )
1314+ sys .exit (0 )
1315+ else :
1316+ print ("Invalid option. Cancelling operation." )
1317+ sys .exit (0 )
1318+ finally :
1319+ # Clean up temporary file
1320+ try :
1321+ os .unlink (temp_requirements_path )
1322+ except Exception as e :
1323+ self .debug (f"Warning: Could not delete temporary requirements file: { e } " , options )
1324+
12091325 def monitor_output (self , connection , options ):
12101326 """Monitor output from the connection with configurable timeout."""
12111327 import signal
@@ -1389,6 +1505,113 @@ def convert_github_url_to_raw(self, url):
13891505 # If we can't parse it, return the original URL
13901506 return url
13911507
1508+ def fetch_remote_command (self , url , options ):
1509+ """
1510+ Fetch a remote command directory or file with associated metadata.
1511+
1512+ Returns:
1513+ tuple: (file_content, info_data, requirements_content, is_directory)
1514+ """
1515+ self .debug (f"Fetching remote command from URL: { url } " , options )
1516+
1517+ # Check if URL ends with .py (file) or treat as directory
1518+ is_python_file = url .endswith ('.py' )
1519+
1520+ if is_python_file :
1521+ # Fetch the Python file and try to get associated metadata
1522+ self .debug ("Detected Python file URL, fetching file and metadata" , options )
1523+ return self ._fetch_remote_python_file (url , options )
1524+ else :
1525+ # Treat as a directory URL (fetch code.py, info.json, requirements.txt)
1526+ self .debug ("Detected directory URL, fetching command files" , options )
1527+ return self ._fetch_remote_directory (url , options )
1528+
1529+ def _fetch_remote_directory (self , url , options ):
1530+ """Fetch code.py, info.json, and requirements.txt from a remote directory."""
1531+ base_url = url .rstrip ('/' )
1532+
1533+ # Try to fetch code.py
1534+ code_url = f"{ base_url } /code.py"
1535+ # Convert individual file URLs for GitHub
1536+ if 'github.com' in code_url :
1537+ code_url = self .convert_github_url_to_raw (code_url )
1538+
1539+ file_content = None
1540+ try :
1541+ file_content = self .fetch_url_content (code_url , options )
1542+ self .debug (f"Successfully fetched code.py from { code_url } " , options )
1543+ except Exception as e :
1544+ print (f"Error: Could not fetch code.py from { code_url } : { e } " )
1545+ sys .exit (1 )
1546+
1547+ # Try to fetch info.json
1548+ info_data = None
1549+ info_url = f"{ base_url } /info.json"
1550+ # Convert individual file URLs for GitHub
1551+ if 'github.com' in info_url :
1552+ info_url = self .convert_github_url_to_raw (info_url )
1553+
1554+ try :
1555+ info_content = self .fetch_url_content (info_url , options )
1556+ info_data = json .loads (info_content )
1557+ self .debug (f"Successfully fetched and parsed info.json from { info_url } " , options )
1558+ except Exception as e :
1559+ self .debug (f"Could not fetch info.json from { info_url } : { e } " , options )
1560+ # info.json is optional, continue without it
1561+
1562+ # Try to fetch requirements.txt
1563+ requirements_content = None
1564+ requirements_url = f"{ base_url } /requirements.txt"
1565+ # Convert individual file URLs for GitHub
1566+ if 'github.com' in requirements_url :
1567+ requirements_url = self .convert_github_url_to_raw (requirements_url )
1568+
1569+ try :
1570+ requirements_content = self .fetch_url_content (requirements_url , options )
1571+ self .debug (f"Successfully fetched requirements.txt from { requirements_url } " , options )
1572+ except Exception as e :
1573+ self .debug (f"Could not fetch requirements.txt from { requirements_url } : { e } " , options )
1574+ # requirements.txt is optional, continue without it
1575+
1576+ return file_content , info_data , requirements_content , True
1577+
1578+ def _fetch_remote_python_file (self , url , options ):
1579+ """Fetch a Python file and try to get associated metadata files."""
1580+ # Fetch the main Python file
1581+ try :
1582+ file_content = self .fetch_url_content (url , options )
1583+ self .debug (f"Successfully fetched Python file from { url } " , options )
1584+ except Exception as e :
1585+ print (f"Error fetching Python file from { url } : { e } " )
1586+ sys .exit (1 )
1587+
1588+ # Try to fetch associated info.json and requirements.txt
1589+ # Use standard filenames in the same directory
1590+ base_url = url .rsplit ('/' , 1 )[0 ] + '/'
1591+
1592+ # Try to fetch info.json
1593+ info_data = None
1594+ info_url = f"{ base_url } info.json"
1595+ try :
1596+ info_content = self .fetch_url_content (info_url , options )
1597+ info_data = json .loads (info_content )
1598+ self .debug (f"Successfully fetched and parsed info.json from { info_url } " , options )
1599+ except Exception as e :
1600+ self .debug (f"Could not fetch info.json from { info_url } : { e } " , options )
1601+ # info.json is optional, continue without it
1602+
1603+ # Try to fetch requirements.txt
1604+ requirements_content = None
1605+ requirements_url = f"{ base_url } requirements.txt"
1606+ try :
1607+ requirements_content = self .fetch_url_content (requirements_url , options )
1608+ self .debug (f"Successfully fetched requirements.txt from { requirements_url } " , options )
1609+ except Exception as e :
1610+ self .debug (f"Could not fetch requirements.txt from { requirements_url } : { e } " , options )
1611+ # requirements.txt is optional, continue without it
1612+
1613+ return file_content , info_data , requirements_content , False
1614+
13921615 def monitor_websocket_output (self , connection , options ):
13931616 """Monitor output from WebSocket connection."""
13941617 buffer = ""
0 commit comments