pywebexec 0.0.3__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.
- pywebexec/__init__.py +5 -0
- pywebexec/pywebexec.py +284 -0
- pywebexec/static/images/aborted.svg +1 -0
- pywebexec/static/images/copy.svg +1 -0
- pywebexec/static/images/copy_ok.svg +1 -0
- pywebexec/static/images/failed.svg +1 -0
- pywebexec/static/images/running.svg +1 -0
- pywebexec/static/images/success.svg +1 -0
- pywebexec/templates/__init__.py +0 -0
- pywebexec/templates/index.html +265 -0
- pywebexec/version.py +16 -0
- pywebexec-0.0.3.dist-info/LICENSE +21 -0
- pywebexec-0.0.3.dist-info/METADATA +130 -0
- pywebexec-0.0.3.dist-info/RECORD +17 -0
- pywebexec-0.0.3.dist-info/WHEEL +5 -0
- pywebexec-0.0.3.dist-info/entry_points.txt +2 -0
- pywebexec-0.0.3.dist-info/top_level.txt +1 -0
pywebexec/__init__.py
ADDED
pywebexec/pywebexec.py
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
from flask import Flask, request, jsonify, render_template
|
|
2
|
+
from flask_httpauth import HTTPBasicAuth
|
|
3
|
+
import subprocess
|
|
4
|
+
import threading
|
|
5
|
+
import os
|
|
6
|
+
import json
|
|
7
|
+
import uuid
|
|
8
|
+
import argparse
|
|
9
|
+
import random
|
|
10
|
+
import string
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
import shlex
|
|
13
|
+
from gunicorn.app.base import BaseApplication
|
|
14
|
+
|
|
15
|
+
app = Flask(__name__)
|
|
16
|
+
auth = HTTPBasicAuth()
|
|
17
|
+
|
|
18
|
+
# Directory to store the script status and output
|
|
19
|
+
SCRIPT_STATUS_DIR = 'script_status'
|
|
20
|
+
|
|
21
|
+
if not os.path.exists(SCRIPT_STATUS_DIR):
|
|
22
|
+
os.makedirs(SCRIPT_STATUS_DIR)
|
|
23
|
+
|
|
24
|
+
def generate_random_password(length=12):
|
|
25
|
+
characters = string.ascii_letters + string.digits + string.punctuation
|
|
26
|
+
return ''.join(random.choice(characters) for i in range(length))
|
|
27
|
+
|
|
28
|
+
class StandaloneApplication(BaseApplication):
|
|
29
|
+
|
|
30
|
+
def __init__(self, app, options=None):
|
|
31
|
+
self.options = options or {}
|
|
32
|
+
self.application = app
|
|
33
|
+
super().__init__()
|
|
34
|
+
|
|
35
|
+
def load_config(self):
|
|
36
|
+
config = {
|
|
37
|
+
key: value for key, value in self.options.items()
|
|
38
|
+
if key in self.cfg.settings and value is not None
|
|
39
|
+
}
|
|
40
|
+
for key, value in config.items():
|
|
41
|
+
self.cfg.set(key.lower(), value)
|
|
42
|
+
|
|
43
|
+
def load(self):
|
|
44
|
+
return self.application
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def start_gunicorn():
|
|
48
|
+
options = {
|
|
49
|
+
'bind': '%s:%s' % (args.listen, args.port),
|
|
50
|
+
'workers': 4,
|
|
51
|
+
'certfile': args.cert,
|
|
52
|
+
'keyfile': args.key,
|
|
53
|
+
}
|
|
54
|
+
StandaloneApplication(app, options=options).run()
|
|
55
|
+
|
|
56
|
+
def parseargs():
|
|
57
|
+
global app, args
|
|
58
|
+
parser = argparse.ArgumentParser(description='Run the script execution server.')
|
|
59
|
+
parser.add_argument('--user', help='Username for basic auth')
|
|
60
|
+
parser.add_argument('--password', help='Password for basic auth')
|
|
61
|
+
parser.add_argument(
|
|
62
|
+
"-l", "--listen", type=str, default="0.0.0.0", help="HTTP server listen address"
|
|
63
|
+
)
|
|
64
|
+
parser.add_argument(
|
|
65
|
+
"-p", "--port", type=int, default=8080, help="HTTP server listen port"
|
|
66
|
+
)
|
|
67
|
+
parser.add_argument(
|
|
68
|
+
"-d", "--dir", type=str, default=os.getcwd(), help="Serve target directory"
|
|
69
|
+
)
|
|
70
|
+
parser.add_argument(
|
|
71
|
+
"-t",
|
|
72
|
+
"--title",
|
|
73
|
+
type=str,
|
|
74
|
+
default="FileBrowser",
|
|
75
|
+
help="Web html title",
|
|
76
|
+
)
|
|
77
|
+
parser.add_argument("-c", "--cert", type=str, help="Path to https certificate")
|
|
78
|
+
parser.add_argument("-k", "--key", type=str, help="Path to https certificate key")
|
|
79
|
+
|
|
80
|
+
args = parser.parse_args()
|
|
81
|
+
|
|
82
|
+
if args.user:
|
|
83
|
+
app.config['USER'] = args.user
|
|
84
|
+
if args.password:
|
|
85
|
+
app.config['PASSWORD'] = args.password
|
|
86
|
+
else:
|
|
87
|
+
app.config['PASSWORD'] = generate_random_password()
|
|
88
|
+
print(f'Generated password for user {args.user}: {app.config["PASSWORD"]}')
|
|
89
|
+
else:
|
|
90
|
+
app.config['USER'] = None
|
|
91
|
+
app.config['PASSWORD'] = None
|
|
92
|
+
return args
|
|
93
|
+
|
|
94
|
+
parseargs()
|
|
95
|
+
|
|
96
|
+
def get_status_file_path(script_id):
|
|
97
|
+
return os.path.join(SCRIPT_STATUS_DIR, f'{script_id}.json')
|
|
98
|
+
|
|
99
|
+
def get_output_file_path(script_id):
|
|
100
|
+
return os.path.join(SCRIPT_STATUS_DIR, f'{script_id}_output.txt')
|
|
101
|
+
|
|
102
|
+
def update_script_status(script_id, status, script_name=None, params=None, start_time=None, end_time=None, exit_code=None):
|
|
103
|
+
status_file_path = get_status_file_path(script_id)
|
|
104
|
+
status_data = read_script_status(script_id) or {}
|
|
105
|
+
status_data['status'] = status
|
|
106
|
+
if script_name is not None:
|
|
107
|
+
status_data['script_name'] = script_name
|
|
108
|
+
if params is not None:
|
|
109
|
+
status_data['params'] = params
|
|
110
|
+
if start_time is not None:
|
|
111
|
+
status_data['start_time'] = start_time
|
|
112
|
+
if end_time is not None:
|
|
113
|
+
status_data['end_time'] = end_time
|
|
114
|
+
if exit_code is not None:
|
|
115
|
+
status_data['exit_code'] = exit_code
|
|
116
|
+
with open(status_file_path, 'w') as f:
|
|
117
|
+
json.dump(status_data, f)
|
|
118
|
+
|
|
119
|
+
def read_script_status(script_id):
|
|
120
|
+
status_file_path = get_status_file_path(script_id)
|
|
121
|
+
if not os.path.exists(status_file_path):
|
|
122
|
+
return None
|
|
123
|
+
with open(status_file_path, 'r') as f:
|
|
124
|
+
return json.load(f)
|
|
125
|
+
|
|
126
|
+
# Dictionary to store the process objects
|
|
127
|
+
processes = {}
|
|
128
|
+
|
|
129
|
+
def run_script(script_name, params, script_id):
|
|
130
|
+
start_time = datetime.now().isoformat()
|
|
131
|
+
update_script_status(script_id, 'running', script_name=script_name, params=params, start_time=start_time)
|
|
132
|
+
try:
|
|
133
|
+
output_file_path = get_output_file_path(script_id)
|
|
134
|
+
with open(output_file_path, 'w') as output_file:
|
|
135
|
+
# Run the script with parameters and redirect stdout and stderr to the file
|
|
136
|
+
process = subprocess.Popen([script_name] + params, stdout=output_file, stderr=output_file, text=True)
|
|
137
|
+
processes[script_id] = process
|
|
138
|
+
process.wait()
|
|
139
|
+
processes.pop(script_id, None)
|
|
140
|
+
|
|
141
|
+
end_time = datetime.now().isoformat()
|
|
142
|
+
# Update the status based on the result
|
|
143
|
+
if process.returncode == 0:
|
|
144
|
+
update_script_status(script_id, 'success', end_time=end_time, exit_code=process.returncode)
|
|
145
|
+
elif process.returncode == -15:
|
|
146
|
+
update_script_status(script_id, 'aborted', end_time=end_time, exit_code=process.returncode)
|
|
147
|
+
else:
|
|
148
|
+
update_script_status(script_id, 'failed', end_time=end_time, exit_code=process.returncode)
|
|
149
|
+
except Exception as e:
|
|
150
|
+
end_time = datetime.now().isoformat()
|
|
151
|
+
update_script_status(script_id, 'failed', end_time=end_time, exit_code=1)
|
|
152
|
+
with open(get_output_file_path(script_id), 'a') as output_file:
|
|
153
|
+
output_file.write(str(e))
|
|
154
|
+
|
|
155
|
+
def auth_required(f):
|
|
156
|
+
if app.config.get('USER'):
|
|
157
|
+
return auth.login_required(f)
|
|
158
|
+
return f
|
|
159
|
+
|
|
160
|
+
@app.route('/run_script', methods=['POST'])
|
|
161
|
+
@auth_required
|
|
162
|
+
def run_script_endpoint():
|
|
163
|
+
data = request.json
|
|
164
|
+
script_name = data.get('script_name')
|
|
165
|
+
params = data.get('params', [])
|
|
166
|
+
|
|
167
|
+
if not script_name:
|
|
168
|
+
return jsonify({'error': 'script_name is required'}), 400
|
|
169
|
+
|
|
170
|
+
# Ensure the script is an executable in the current directory
|
|
171
|
+
script_path = os.path.join(".", os.path.basename(script_name))
|
|
172
|
+
if not os.path.isfile(script_path) or not os.access(script_path, os.X_OK):
|
|
173
|
+
return jsonify({'error': 'script_name must be an executable in the current directory'}), 400
|
|
174
|
+
|
|
175
|
+
# Split params using shell-like syntax
|
|
176
|
+
try:
|
|
177
|
+
params = shlex.split(' '.join(params))
|
|
178
|
+
except ValueError as e:
|
|
179
|
+
return jsonify({'error': str(e)}), 400
|
|
180
|
+
|
|
181
|
+
# Generate a unique script_id
|
|
182
|
+
script_id = str(uuid.uuid4())
|
|
183
|
+
|
|
184
|
+
# Set the initial status to running and save script details
|
|
185
|
+
update_script_status(script_id, 'running', script_name, params)
|
|
186
|
+
|
|
187
|
+
# Run the script in a separate thread
|
|
188
|
+
thread = threading.Thread(target=run_script, args=(script_path, params, script_id))
|
|
189
|
+
thread.start()
|
|
190
|
+
|
|
191
|
+
return jsonify({'message': 'Script is running', 'script_id': script_id})
|
|
192
|
+
|
|
193
|
+
@app.route('/stop_script/<script_id>', methods=['POST'])
|
|
194
|
+
@auth_required
|
|
195
|
+
def stop_script(script_id):
|
|
196
|
+
process = processes.get(script_id)
|
|
197
|
+
end_time = datetime.now().isoformat()
|
|
198
|
+
if process and process.poll() is None:
|
|
199
|
+
try:
|
|
200
|
+
process.terminate()
|
|
201
|
+
process.wait() # Ensure the process has terminated
|
|
202
|
+
return jsonify({'message': 'Script aborted'})
|
|
203
|
+
except Exception as e:
|
|
204
|
+
status_data = read_script_status(script_id) or {}
|
|
205
|
+
status_data['status'] = 'failed'
|
|
206
|
+
status_data['end_time'] = end_time
|
|
207
|
+
status_data['exit_code'] = 1
|
|
208
|
+
with open(get_status_file_path(script_id), 'w') as f:
|
|
209
|
+
json.dump(status_data, f)
|
|
210
|
+
with open(get_output_file_path(script_id), 'a') as output_file:
|
|
211
|
+
output_file.write(str(e))
|
|
212
|
+
return jsonify({'error': 'Failed to terminate script'}), 500
|
|
213
|
+
update_script_status(script_id, 'failed', end_time=end_time, exit_code=1)
|
|
214
|
+
return jsonify({'error': 'Invalid script_id or script not running'}), 400
|
|
215
|
+
|
|
216
|
+
@app.route('/script_status/<script_id>', methods=['GET'])
|
|
217
|
+
@auth_required
|
|
218
|
+
def get_script_status(script_id):
|
|
219
|
+
status = read_script_status(script_id)
|
|
220
|
+
if not status:
|
|
221
|
+
return jsonify({'error': 'Invalid script_id'}), 404
|
|
222
|
+
|
|
223
|
+
output_file_path = get_output_file_path(script_id)
|
|
224
|
+
if os.path.exists(output_file_path):
|
|
225
|
+
with open(output_file_path, 'r') as output_file:
|
|
226
|
+
output = output_file.read()
|
|
227
|
+
status['output'] = output
|
|
228
|
+
|
|
229
|
+
return jsonify(status)
|
|
230
|
+
|
|
231
|
+
@app.route('/')
|
|
232
|
+
@auth_required
|
|
233
|
+
def index():
|
|
234
|
+
return render_template('index.html')
|
|
235
|
+
|
|
236
|
+
@app.route('/scripts', methods=['GET'])
|
|
237
|
+
@auth_required
|
|
238
|
+
def list_scripts():
|
|
239
|
+
scripts = []
|
|
240
|
+
for filename in os.listdir(SCRIPT_STATUS_DIR):
|
|
241
|
+
if filename.endswith('.json'):
|
|
242
|
+
script_id = filename[:-5]
|
|
243
|
+
status = read_script_status(script_id)
|
|
244
|
+
if status:
|
|
245
|
+
command = status['script_name'] + ' ' + shlex.join(status['params'])
|
|
246
|
+
scripts.append({
|
|
247
|
+
'script_id': script_id,
|
|
248
|
+
'status': status['status'],
|
|
249
|
+
'start_time': status.get('start_time', 'N/A'),
|
|
250
|
+
'end_time': status.get('end_time', 'N/A'),
|
|
251
|
+
'command': command,
|
|
252
|
+
'exit_code': status.get('exit_code', 'N/A')
|
|
253
|
+
})
|
|
254
|
+
# Sort scripts by start_time in descending order
|
|
255
|
+
scripts.sort(key=lambda x: x['start_time'], reverse=True)
|
|
256
|
+
return jsonify(scripts)
|
|
257
|
+
|
|
258
|
+
@app.route('/script_output/<script_id>', methods=['GET'])
|
|
259
|
+
@auth_required
|
|
260
|
+
def get_script_output(script_id):
|
|
261
|
+
output_file_path = get_output_file_path(script_id)
|
|
262
|
+
if os.path.exists(output_file_path):
|
|
263
|
+
with open(output_file_path, 'r') as output_file:
|
|
264
|
+
output = output_file.read()
|
|
265
|
+
status_data = read_script_status(script_id) or {}
|
|
266
|
+
return jsonify({'output': output, 'status': status_data.get("status")})
|
|
267
|
+
return jsonify({'error': 'Invalid script_id'}), 404
|
|
268
|
+
|
|
269
|
+
@app.route('/executables', methods=['GET'])
|
|
270
|
+
@auth_required
|
|
271
|
+
def list_executables():
|
|
272
|
+
executables = [f for f in os.listdir('.') if os.path.isfile(f) and os.access(f, os.X_OK)]
|
|
273
|
+
return jsonify(executables)
|
|
274
|
+
|
|
275
|
+
@auth.verify_password
|
|
276
|
+
def verify_password(username, password):
|
|
277
|
+
return username == app.config['USER'] and password == app.config['PASSWORD']
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
if __name__ == '__main__':
|
|
283
|
+
start_gunicorn()
|
|
284
|
+
#app.run(host='0.0.0.0', port=5000)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg viewBox="-1 -1 13 13" xmlns="http://www.w3.org/2000/svg" fill="#000000"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"><path fill-rule="evenodd" clip-rule="evenodd" d="M6 12A6 6 0 106 0a6 6 0 000 12zM3 5a1 1 0 000 2h6a1 1 0 100-2H3z" fill="#ff641a"></path></g></svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false" role="img" viewBox="0 0 16 16" width="25" height="16" fill="currentColor"><path d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z"></path><path d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z"></path></svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false" role="img" viewBox="0 0 16 16" width="25" height="16" fill="currentColor" style="display: inline-block; user-select: none; vertical-align: text-bottom; overflow: visible;"><path fill="#118811" d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z"></path></svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg viewBox="-2 -2 34 34" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:sketch="http://www.bohemiancoding.com/sketch/ns" fill="#000000"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"><title>cross-circle</title><desc>Created with Sketch Beta.</desc><defs></defs><g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" sketch:type="MSPage"><g id="Icon-Set-Filled" sketch:type="MSLayerGroup" transform="translate(-570.000000, -1089.000000)" fill="#ca0000"><path d="M591.657,1109.24 C592.048,1109.63 592.048,1110.27 591.657,1110.66 C591.267,1111.05 590.633,1111.05 590.242,1110.66 L586.006,1106.42 L581.74,1110.69 C581.346,1111.08 580.708,1111.08 580.314,1110.69 C579.921,1110.29 579.921,1109.65 580.314,1109.26 L584.58,1104.99 L580.344,1100.76 C579.953,1100.37 579.953,1099.73 580.344,1099.34 C580.733,1098.95 581.367,1098.95 581.758,1099.34 L585.994,1103.58 L590.292,1099.28 C590.686,1098.89 591.323,1098.89 591.717,1099.28 C592.11,1099.68 592.11,1100.31 591.717,1100.71 L587.42,1105.01 L591.657,1109.24 L591.657,1109.24 Z M586,1089 C577.163,1089 570,1096.16 570,1105 C570,1113.84 577.163,1121 586,1121 C594.837,1121 602,1113.84 602,1105 C602,1096.16 594.837,1089 586,1089 L586,1089 Z" id="cross-circle" sketch:type="MSShapeGroup"></path></g></g></g></svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg viewBox="0 0 1024 1024" class="icon" version="1.1" xmlns="http://www.w3.org/2000/svg" fill="#000000"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"><path d="M661.333333 170.666667l253.866667 34.133333-209.066667 209.066667zM362.666667 853.333333L108.8 819.2l209.066667-209.066667zM170.666667 362.666667L204.8 108.8l209.066667 209.066667z" fill="#4949d1"></path><path d="M198.4 452.266667l-89.6 17.066666c-2.133333 14.933333-2.133333 27.733333-2.133333 42.666667 0 98.133333 34.133333 192 98.133333 264.533333l64-55.466666C219.733333 663.466667 192 588.8 192 512c0-19.2 2.133333-40.533333 6.4-59.733333zM512 106.666667c-115.2 0-217.6 49.066667-292.266667 125.866666l59.733334 59.733334C339.2 230.4 420.266667 192 512 192c19.2 0 40.533333 2.133333 59.733333 6.4l14.933334-83.2C563.2 108.8 537.6 106.666667 512 106.666667zM825.6 571.733333l89.6-17.066666c2.133333-14.933333 2.133333-27.733333 2.133333-42.666667 0-93.866667-32-185.6-91.733333-258.133333l-66.133333 53.333333c46.933333 57.6 72.533333 130.133333 72.533333 202.666667 0 21.333333-2.133333 42.666667-6.4 61.866666zM744.533333 731.733333C684.8 793.6 603.733333 832 512 832c-19.2 0-40.533333-2.133333-59.733333-6.4l-14.933334 83.2c25.6 4.266667 51.2 6.4 74.666667 6.4 115.2 0 217.6-49.066667 292.266667-125.866667l-59.733334-57.6z" fill="#4949d1"></path><path d="M853.333333 661.333333l-34.133333 253.866667-209.066667-209.066667z" fill="#4949d1"></path></g></svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg viewBox="0 75 949 949" xmlns="http://www.w3.org/2000/svg" fill="#000000"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"><path fill="#00a600" d="M512 64a448 448 0 1 1 0 896 448 448 0 0 1 0-896zm-55.808 536.384-99.52-99.584a38.4 38.4 0 1 0-54.336 54.336l126.72 126.72a38.272 38.272 0 0 0 54.336 0l262.4-262.464a38.4 38.4 0 1 0-54.272-54.336L456.192 600.384z"></path></g></svg>
|
|
File without changes
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<title>pywebexec</title>
|
|
6
|
+
<style>
|
|
7
|
+
body { font-family: Arial, sans-serif; }
|
|
8
|
+
.table-container { max-height: 385px; overflow-y: auto; }
|
|
9
|
+
table { width: 100%; border-collapse: collapse; }
|
|
10
|
+
th, td { padding: 8px; text-align: left; border-bottom: 1px solid #ddd; }
|
|
11
|
+
th { background-color: #f2f2f2; position: sticky; top: 0; z-index: 1; }
|
|
12
|
+
.output {
|
|
13
|
+
white-space: pre-wrap;
|
|
14
|
+
background: #f0f0f0;
|
|
15
|
+
padding: 10px;
|
|
16
|
+
border: 1px solid #ccc;
|
|
17
|
+
font-family: monospace;
|
|
18
|
+
border-radius: 15px;
|
|
19
|
+
}
|
|
20
|
+
.copy-icon { cursor: pointer; }
|
|
21
|
+
.monospace { font-family: monospace; }
|
|
22
|
+
.copied { color: green; margin-left: 5px; }
|
|
23
|
+
button {
|
|
24
|
+
-webkit-appearance: none;
|
|
25
|
+
-webkit-border-radius: none;
|
|
26
|
+
appearance: none;
|
|
27
|
+
border-radius: 15px;
|
|
28
|
+
padding: 3px;
|
|
29
|
+
padding-right: 13px;
|
|
30
|
+
border: 1px #555 solid;
|
|
31
|
+
height: 22px;
|
|
32
|
+
font-size: 13px;
|
|
33
|
+
outline: none;
|
|
34
|
+
text-indent: 10px;
|
|
35
|
+
background-color: #eee;
|
|
36
|
+
display: inline-block;
|
|
37
|
+
vertical-align: middle;
|
|
38
|
+
}
|
|
39
|
+
form {
|
|
40
|
+
padding-bottom: 15px;
|
|
41
|
+
}
|
|
42
|
+
.status-icon {
|
|
43
|
+
display: inline-block;
|
|
44
|
+
width: 16px;
|
|
45
|
+
height: 16px;
|
|
46
|
+
margin-right: 5px;
|
|
47
|
+
background-size: contain;
|
|
48
|
+
background-repeat: no-repeat;
|
|
49
|
+
vertical-align: middle;
|
|
50
|
+
}
|
|
51
|
+
.status-running {
|
|
52
|
+
background-image: url("/static/images/running.svg")
|
|
53
|
+
}
|
|
54
|
+
.status-success {
|
|
55
|
+
background-image: url("/static/images/success.svg")
|
|
56
|
+
}
|
|
57
|
+
.status-failed {
|
|
58
|
+
background-image: url("/static/images/failed.svg")
|
|
59
|
+
}
|
|
60
|
+
.status-aborted {
|
|
61
|
+
background-image: url("/static/images/aborted.svg")
|
|
62
|
+
}
|
|
63
|
+
.copy_clip {
|
|
64
|
+
padding-right: 25px;
|
|
65
|
+
background-repeat: no-repeat;
|
|
66
|
+
background-position: right top;
|
|
67
|
+
background-size: 25px 16px;
|
|
68
|
+
white-space: nowrap;
|
|
69
|
+
}
|
|
70
|
+
.copy_clip_left {
|
|
71
|
+
padding-left: 25px;
|
|
72
|
+
padding-right: 0px;
|
|
73
|
+
background-position: left top;
|
|
74
|
+
}
|
|
75
|
+
.copy_clip:hover {
|
|
76
|
+
cursor: pointer;
|
|
77
|
+
background-image: url("/static/images/copy.svg");
|
|
78
|
+
}
|
|
79
|
+
.copy_clip_ok, .copy_clip_ok:hover {
|
|
80
|
+
background-image: url("/static/images/copy_ok.svg");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
</style>
|
|
84
|
+
</head>
|
|
85
|
+
<body>
|
|
86
|
+
<h1>pywebexec</h1>
|
|
87
|
+
<form id="launchForm">
|
|
88
|
+
<label for="scriptName">Command:</label>
|
|
89
|
+
<select id="scriptName" name="scriptName"></select>
|
|
90
|
+
<label for="params">Params:</label>
|
|
91
|
+
<input type="text" id="params" name="params">
|
|
92
|
+
<button type="submit">Launch</button>
|
|
93
|
+
</form>
|
|
94
|
+
<div class="table-container">
|
|
95
|
+
<table>
|
|
96
|
+
<thead>
|
|
97
|
+
<tr>
|
|
98
|
+
<th>Script ID</th>
|
|
99
|
+
<th>Status</th>
|
|
100
|
+
<th>Start Time</th>
|
|
101
|
+
<th>Duration</th>
|
|
102
|
+
<th>Exit</th>
|
|
103
|
+
<th>Command</th>
|
|
104
|
+
<th>Actions</th>
|
|
105
|
+
</tr>
|
|
106
|
+
</thead>
|
|
107
|
+
<tbody id="scripts"></tbody>
|
|
108
|
+
</table>
|
|
109
|
+
</div>
|
|
110
|
+
<div id="output" class="output"></div>
|
|
111
|
+
|
|
112
|
+
<script>
|
|
113
|
+
let currentScriptId = null;
|
|
114
|
+
let outputInterval = null;
|
|
115
|
+
|
|
116
|
+
document.getElementById('launchForm').addEventListener('submit', async (event) => {
|
|
117
|
+
event.preventDefault();
|
|
118
|
+
const scriptName = document.getElementById('scriptName').value;
|
|
119
|
+
const params = document.getElementById('params').value.split(' ');
|
|
120
|
+
const response = await fetch('/run_script', {
|
|
121
|
+
method: 'POST',
|
|
122
|
+
headers: {
|
|
123
|
+
'Content-Type': 'application/json'
|
|
124
|
+
},
|
|
125
|
+
body: JSON.stringify({ script_name: scriptName, params: params })
|
|
126
|
+
});
|
|
127
|
+
const data = await response.json();
|
|
128
|
+
fetchScripts();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
async function fetchScripts() {
|
|
132
|
+
const response = await fetch('/scripts');
|
|
133
|
+
const scripts = await response.json();
|
|
134
|
+
scripts.sort((a, b) => new Date(b.start_time) - new Date(a.start_time));
|
|
135
|
+
const scriptsTbody = document.getElementById('scripts');
|
|
136
|
+
scriptsTbody.innerHTML = '';
|
|
137
|
+
scripts.forEach(script => {
|
|
138
|
+
const scriptRow = document.createElement('tr');
|
|
139
|
+
scriptRow.innerHTML = `
|
|
140
|
+
<td class="monospace">
|
|
141
|
+
<span class="copy_clip" onclick="copyToClipboard('${script.script_id}', this)">${script.script_id.slice(0, 8)}</span>
|
|
142
|
+
</td>
|
|
143
|
+
<td><span class="status-icon status-${script.status}"></span>${script.status}</td>
|
|
144
|
+
<td>${formatTime(script.start_time)}</td>
|
|
145
|
+
<td>${script.status === 'running' ? formatDuration(script.start_time, new Date().toISOString()) : formatDuration(script.start_time, script.end_time)}</td>
|
|
146
|
+
<td>${script.exit_code}</td>
|
|
147
|
+
<td>${script.command.replace(/^\.\//, '')}</td>
|
|
148
|
+
<td>
|
|
149
|
+
<button onclick="viewOutput('${script.script_id}')">Log</button>
|
|
150
|
+
<button onclick="relaunchScript('${script.script_id}')">Relaunch</button>
|
|
151
|
+
${script.status === 'running' ? `<button onclick="stopScript('${script.script_id}')">Stop</button>` : ''}
|
|
152
|
+
</td>
|
|
153
|
+
`;
|
|
154
|
+
scriptsTbody.appendChild(scriptRow);
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function fetchExecutables() {
|
|
159
|
+
const response = await fetch('/executables');
|
|
160
|
+
const executables = await response.json();
|
|
161
|
+
const scriptNameSelect = document.getElementById('scriptName');
|
|
162
|
+
scriptNameSelect.innerHTML = '';
|
|
163
|
+
executables.forEach(executable => {
|
|
164
|
+
const option = document.createElement('option');
|
|
165
|
+
option.value = executable;
|
|
166
|
+
option.textContent = executable;
|
|
167
|
+
scriptNameSelect.appendChild(option);
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function fetchOutput(script_id) {
|
|
172
|
+
const outputDiv = document.getElementById('output');
|
|
173
|
+
const response = await fetch(`/script_output/${script_id}`);
|
|
174
|
+
const data = await response.json();
|
|
175
|
+
if (data.error) {
|
|
176
|
+
outputDiv.innerHTML = data.error;
|
|
177
|
+
clearInterval(outputInterval);
|
|
178
|
+
} else {
|
|
179
|
+
outputDiv.innerHTML = data.output;
|
|
180
|
+
if (data.status != 'running') {
|
|
181
|
+
clearInterval(outputInterval)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function viewOutput(script_id) {
|
|
187
|
+
currentScriptId = script_id;
|
|
188
|
+
clearInterval(outputInterval);
|
|
189
|
+
const response = await fetch(`/script_status/${script_id}`);
|
|
190
|
+
const data = await response.json();
|
|
191
|
+
if (data.status === 'running') {
|
|
192
|
+
fetchOutput(script_id);
|
|
193
|
+
outputInterval = setInterval(() => fetchOutput(script_id), 1000);
|
|
194
|
+
} else {
|
|
195
|
+
fetchOutput(script_id);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function relaunchScript(script_id) {
|
|
200
|
+
const response = await fetch(`/script_status/${script_id}`);
|
|
201
|
+
const data = await response.json();
|
|
202
|
+
if (data.error) {
|
|
203
|
+
alert(data.error);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
const relaunchResponse = await fetch('/run_script', {
|
|
207
|
+
method: 'POST',
|
|
208
|
+
headers: {
|
|
209
|
+
'Content-Type': 'application/json'
|
|
210
|
+
},
|
|
211
|
+
body: JSON.stringify({
|
|
212
|
+
script_name: data.script_name,
|
|
213
|
+
params: data.params
|
|
214
|
+
})
|
|
215
|
+
});
|
|
216
|
+
const relaunchData = await relaunchResponse.json();
|
|
217
|
+
alert(relaunchData.message);
|
|
218
|
+
fetchScripts();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function stopScript(script_id) {
|
|
222
|
+
const response = await fetch(`/stop_script/${script_id}`, {
|
|
223
|
+
method: 'POST'
|
|
224
|
+
});
|
|
225
|
+
const data = await response.json();
|
|
226
|
+
if (data.error) {
|
|
227
|
+
alert(data.error);
|
|
228
|
+
} else {
|
|
229
|
+
alert(data.message);
|
|
230
|
+
fetchScripts();
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function formatTime(time) {
|
|
235
|
+
if (!time || time === 'N/A') return 'N/A';
|
|
236
|
+
const date = new Date(time);
|
|
237
|
+
return date.toISOString().slice(0, 16).replace('T', ' ');
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function formatDuration(startTime, endTime) {
|
|
241
|
+
if (!startTime || !endTime) return 'N/A';
|
|
242
|
+
const start = new Date(startTime);
|
|
243
|
+
const end = new Date(endTime);
|
|
244
|
+
const duration = (end - start) / 1000;
|
|
245
|
+
const hours = Math.floor(duration / 3600);
|
|
246
|
+
const minutes = Math.floor((duration % 3600) / 60);
|
|
247
|
+
const seconds = Math.floor(duration % 60);
|
|
248
|
+
return `${hours}h ${minutes}m ${seconds}s`;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function copyToClipboard(text, element) {
|
|
252
|
+
navigator.clipboard.writeText(text).then(() => {
|
|
253
|
+
element.classList.add('copy_clip_ok')
|
|
254
|
+
setTimeout(() => {
|
|
255
|
+
element.classList.remove('copy_clip_ok');
|
|
256
|
+
}, 2000);
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
fetchScripts();
|
|
261
|
+
fetchExecutables();
|
|
262
|
+
setInterval(fetchScripts, 5000);
|
|
263
|
+
</script>
|
|
264
|
+
</body>
|
|
265
|
+
</html>
|
pywebexec/version.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# file generated by setuptools_scm
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
TYPE_CHECKING = False
|
|
4
|
+
if TYPE_CHECKING:
|
|
5
|
+
from typing import Tuple, Union
|
|
6
|
+
VERSION_TUPLE = Tuple[Union[int, str], ...]
|
|
7
|
+
else:
|
|
8
|
+
VERSION_TUPLE = object
|
|
9
|
+
|
|
10
|
+
version: str
|
|
11
|
+
__version__: str
|
|
12
|
+
__version_tuple__: VERSION_TUPLE
|
|
13
|
+
version_tuple: VERSION_TUPLE
|
|
14
|
+
|
|
15
|
+
__version__ = version = '0.0.3'
|
|
16
|
+
__version_tuple__ = version_tuple = (0, 0, 3)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 joknarf
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: pywebexec
|
|
3
|
+
Version: 0.0.3
|
|
4
|
+
Summary: Simple Python HTTP Exec Server
|
|
5
|
+
Home-page: https://github.com/joknarf/pywebexec
|
|
6
|
+
Author: Franck Jouvanceau
|
|
7
|
+
Maintainer: Franck Jouvanceau
|
|
8
|
+
License: MIT License
|
|
9
|
+
|
|
10
|
+
Copyright (c) 2025 joknarf
|
|
11
|
+
|
|
12
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
13
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
14
|
+
in the Software without restriction, including without limitation the rights
|
|
15
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
16
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
17
|
+
furnished to do so, subject to the following conditions:
|
|
18
|
+
|
|
19
|
+
The above copyright notice and this permission notice shall be included in all
|
|
20
|
+
copies or substantial portions of the Software.
|
|
21
|
+
|
|
22
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
23
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
24
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
25
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
26
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
27
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
28
|
+
SOFTWARE.
|
|
29
|
+
|
|
30
|
+
Project-URL: Homepage, https://github.com/joknarf/pywebexec
|
|
31
|
+
Project-URL: Documentation, https://github.com/joknarf/pywebexec/blob/main/README.md
|
|
32
|
+
Project-URL: Repository, https://github.com/joknarf/pywebexec.git
|
|
33
|
+
Keywords: http,fileserver,browser,explorer
|
|
34
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
35
|
+
Classifier: Intended Audience :: System Administrators
|
|
36
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
37
|
+
Classifier: Operating System :: POSIX
|
|
38
|
+
Classifier: Operating System :: Unix
|
|
39
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
40
|
+
Classifier: Operating System :: MacOS
|
|
41
|
+
Classifier: Programming Language :: Python
|
|
42
|
+
Classifier: Programming Language :: Python :: 3
|
|
43
|
+
Classifier: Programming Language :: Python :: 3.6
|
|
44
|
+
Classifier: Programming Language :: Python :: 3.7
|
|
45
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
46
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
47
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
48
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
49
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
50
|
+
Classifier: Topic :: System :: Clustering
|
|
51
|
+
Classifier: Topic :: System :: Networking
|
|
52
|
+
Classifier: Topic :: System :: Systems Administration
|
|
53
|
+
Requires-Python: >=3.6
|
|
54
|
+
Description-Content-Type: text/markdown
|
|
55
|
+
License-File: LICENSE
|
|
56
|
+
Requires-Dist: cryptography>=40.0.2
|
|
57
|
+
Requires-Dist: Flask>=3.0.3
|
|
58
|
+
Requires-Dist: Flask-HTTPAuth>=4.8.0
|
|
59
|
+
|
|
60
|
+
[](https://pypi.org/project/pywebexec/)
|
|
61
|
+

|
|
62
|
+
[](https://shields.io/)
|
|
63
|
+
[](https://pepy.tech/project/pywebexec)
|
|
64
|
+
[](https://shields.io/)
|
|
65
|
+
|
|
66
|
+
# pywebexec
|
|
67
|
+
Simple Python HTTP(S) API/Web Command Launcher
|
|
68
|
+
|
|
69
|
+
## Install
|
|
70
|
+
```
|
|
71
|
+
$ pip install pywebexec
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Quick start
|
|
75
|
+
|
|
76
|
+
* start http server serving current directory executables listening on 0.0.0.0 port 8080
|
|
77
|
+
```
|
|
78
|
+
$ pywebexec
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
* Launch commands with params/view live output/Status using browser `http://<yourserver>:8080`
|
|
82
|
+
|
|
83
|
+
## features
|
|
84
|
+
|
|
85
|
+
* Serve executables in current directory
|
|
86
|
+
* Launch commands with params from web browser
|
|
87
|
+
* Follow live output
|
|
88
|
+
* Stop command
|
|
89
|
+
* Relaunch command
|
|
90
|
+
* HTTPS support
|
|
91
|
+
* HTTPS self-signed certificate generator
|
|
92
|
+
* Can be started as a daemon (POSIX)
|
|
93
|
+
* uses gunicorn to serve http/https
|
|
94
|
+
|
|
95
|
+
## Customize server
|
|
96
|
+
```
|
|
97
|
+
$ pywebexec --listen 0.0.0.0 --port 8080
|
|
98
|
+
$ pywebexec -l 0.0.0.0 -p 8080
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Basic auth user/password
|
|
102
|
+
```
|
|
103
|
+
$ pywebexec --user myuser [--password mypass]
|
|
104
|
+
$ pywebfs -u myuser [-P mypass]
|
|
105
|
+
```
|
|
106
|
+
Generated password is given if no `--pasword` option
|
|
107
|
+
|
|
108
|
+
## HTTPS server
|
|
109
|
+
|
|
110
|
+
* Generate auto-signed certificate and start https server
|
|
111
|
+
```
|
|
112
|
+
$ pywebfs --gencert
|
|
113
|
+
$ pywebfs --g
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
* Start https server using existing certificate
|
|
117
|
+
```
|
|
118
|
+
$ pywebfs --cert /pathto/host.cert --key /pathto/host.key
|
|
119
|
+
$ pywebfs -c /pathto/host.cert -k /pathto/host.key
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Launch server as a daemon (Linux)
|
|
123
|
+
|
|
124
|
+
```
|
|
125
|
+
$ pywebexec start
|
|
126
|
+
$ pywebexec status
|
|
127
|
+
$ pywebexec stop
|
|
128
|
+
```
|
|
129
|
+
* log of server are stored in current directory `.web_status/pwexec_<listen>:<port>.log`
|
|
130
|
+
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
pywebexec/__init__.py,sha256=4spIsVaF8RJt8S58AG_wWoORRNkws9Iwqprj27C3ljM,99
|
|
2
|
+
pywebexec/pywebexec.py,sha256=mnnlYkRg8HpWdfcVamqEw0zFqy-ZVNVEVXYttecRTyg,10212
|
|
3
|
+
pywebexec/version.py,sha256=hB095avW4HuDZxn8qPHRG1UMzSSonb8ZDAsLxt9hmk8,411
|
|
4
|
+
pywebexec/static/images/aborted.svg,sha256=_mP43hU5QdRLFZIknBgjx-dIXrHgQG23-QV27ApXK2A,381
|
|
5
|
+
pywebexec/static/images/copy.svg,sha256=d9OwtGh5GzzZHzYcDrLfNxZYLth1Q64x7bRyYxu4Px0,622
|
|
6
|
+
pywebexec/static/images/copy_ok.svg,sha256=mEqUVUhSq8xaJK2msQkxRawnz_KwlCZ-tok8QS6hJ3g,451
|
|
7
|
+
pywebexec/static/images/failed.svg,sha256=ADZ7IKrUyOXtqpivnz3VcH0-Wru-I5MOi3OJAkI3hxk,1439
|
|
8
|
+
pywebexec/static/images/running.svg,sha256=vBpiG6ClNUNCArkwsyqK7O-qhIKJX1NI7MSjclNSp_8,1537
|
|
9
|
+
pywebexec/static/images/success.svg,sha256=PJDcCSTevJh7rkfSFLtc7P0pbeh8PVQBS8DaOLQemmc,489
|
|
10
|
+
pywebexec/templates/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
+
pywebexec/templates/index.html,sha256=tS2uVjto2iPpx-0j3aVxAfE564dJJKMzY0_dBC-vGjE,9813
|
|
12
|
+
pywebexec-0.0.3.dist-info/LICENSE,sha256=gRJf0JPT_wsZJsUGlWPTS8Vypfl9vQ1qjp6sNbKykuA,1064
|
|
13
|
+
pywebexec-0.0.3.dist-info/METADATA,sha256=9CAAkMxOY5LxHf8boAIwPj2FPgu7G2r3PMda-4U8xrw,4623
|
|
14
|
+
pywebexec-0.0.3.dist-info/WHEEL,sha256=A3WOREP4zgxI0fKrHUG8DC8013e3dK3n7a6HDbcEIwE,91
|
|
15
|
+
pywebexec-0.0.3.dist-info/entry_points.txt,sha256=-6--c27U7RARJe0BiW5CkTuKljf6pRtnDzE0wfmD9TM,65
|
|
16
|
+
pywebexec-0.0.3.dist-info/top_level.txt,sha256=vHoHyzngrfGdm_nM7Xn_5iLmaCrf10XO1EhldgNLEQ8,10
|
|
17
|
+
pywebexec-0.0.3.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pywebexec
|