Skip to content

Commit d98aabd

Browse files
committed
if given a URL that doesn't end in a filename.py, try to load code.py, info.json and requirements.txt from it and use those
1 parent 95b7840 commit d98aabd

2 files changed

Lines changed: 269 additions & 6 deletions

File tree

FAQ.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,46 @@ circremote 192.168.1.100:8080 BME280
6969
circremote -p mypassword 192.168.1.100 BME280
7070
```
7171

72+
### How do I use remote commands from URLs?
73+
74+
circremote supports running commands directly from URLs, including GitHub repositories and other web servers.
75+
76+
**Remote Command Directory (any URL not ending in .py):**
77+
```bash
78+
# Fetch code.py, info.json, and requirements.txt from a directory
79+
circremote /dev/ttyUSB0 https://github.com/user/repo/sensor/
80+
circremote /dev/ttyUSB0 https://example.com/sensors/temperature/
81+
circremote /dev/ttyUSB0 https://github.com/user/repo/tree/main/commands/sensor
82+
circremote /dev/ttyUSB0 https://github.com/romkey/circremote/tree/main/circremote/commands/BME680
83+
```
84+
85+
**Remote Python File (ends with .py):**
86+
```bash
87+
# Fetch a Python file and associated metadata
88+
circremote /dev/ttyUSB0 https://example.com/sensor.py
89+
circremote /dev/ttyUSB0 https://raw.githubusercontent.com/user/repo/main/sensor.py
90+
```
91+
92+
**Single File (any other URL):**
93+
```bash
94+
# Fetch a single file (existing behavior)
95+
circremote /dev/ttyUSB0 https://example.com/script.py
96+
```
97+
98+
**Features:**
99+
- **Automatic metadata**: For directory URLs (any URL not ending in .py), circremote fetches `code.py`, `info.json`, and `requirements.txt`
100+
- **Associated files**: For Python files, circremote tries to fetch `info.json` and `requirements.txt` in the same directory
101+
- **Dependency installation**: Remote `requirements.txt` files trigger automatic circup installation
102+
- **GitHub support**: GitHub URLs are automatically converted to raw content URLs
103+
- **Variable support**: Remote commands support the same variable interpolation as local commands
104+
105+
**Example with dependencies:**
106+
```bash
107+
# This will fetch the command and install any required libraries
108+
circremote /dev/ttyUSB0 https://github.com/user/repo/sensor/
109+
# If requirements.txt exists, circup will be run automatically
110+
```
111+
72112
### How do I enable verbose output?
73113
```bash
74114
circremote -v /dev/ttyUSB0 BME280

circremote/cli.py

Lines changed: 229 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import signal
1212
import urllib.parse
1313
import requests
14+
import tempfile
1415
from pathlib import Path
1516
from argparse import ArgumentParser, Namespace
1617
from 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

Comments
 (0)